From 7b5e3e2d1f097d6ec3bcf51847a26b6c387d6cf1 Mon Sep 17 00:00:00 2001 From: chichuan Date: Thu, 20 Aug 2026 16:29:41 +0800 Subject: [PATCH 01/33] ci: harden coverage baseline governance --- .github/actionlint.yaml | 10 + .github/workflows/ci.yml | 342 +++++++- .../workflows/coverage-baseline-promotion.yml | 322 ++++++++ .../workflows/coverage-baseline-repair.yml | 529 ++++++++++++ .github/workflows/release.yml | 130 +++ .github/workflows/reviewer-router.yml | 756 +++++++++++++++++- docs/automation.md | 159 +++- docs/ci-pr-gates.md | 134 +++- .../coverage_workflow_contract_test.go | 506 +++++++++++- test/scripts/package_script_test.go | 81 +- test/scripts/reviewer_router_workflow_test.go | 469 ++++++++++- 11 files changed, 3357 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/coverage-baseline-promotion.yml create mode 100644 .github/workflows/coverage-baseline-repair.yml diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index eea35124..b8b67108 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -4,3 +4,13 @@ paths: # GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's # bundled workflow schema has not caught up with the platform syntax. - 'unexpected key "queue" for "concurrency" section' + .github/workflows/coverage-baseline-promotion.yml: + ignore: + # Serialize every acknowledgement for one Formula target without + # allowing Actions' default single-pending replacement to orphan a run. + - 'unexpected key "queue" for "concurrency" section' + .github/workflows/coverage-baseline-repair.yml: + ignore: + # Keep the closed-event dispatcher and its exact-SHA producer queued for + # the same target instead of replacing either half of the repair chain. + - 'unexpected key "queue" for "concurrency" section' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08fb7c86..04e93660 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,12 +5,16 @@ on: branches: - main pull_request: + types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled] permissions: contents: read concurrency: - group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # Only duplicate runs for the exact PR base/head revision cancel each other. + # A later revision must not kill an earlier cold-cache producer, and every + # protected-main SHA keeps an independent producer run. + group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}-{1}-{2}', github.event.pull_request.number, github.event.pull_request.base.sha, github.event.pull_request.head.sha) || format('push-{0}', github.sha) }} cancel-in-progress: true jobs: @@ -827,7 +831,9 @@ jobs: if: ${{ always() && needs.lint.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 5 - permissions: {} + permissions: + contents: read + pull-requests: read steps: - name: Verify test shards env: @@ -906,6 +912,174 @@ jobs: done test "$failed" -eq 0 + # Null and non-built-in merge identities emit either the protected-main + # push or the trusted pull_request_target closed repair. The built-in + # Actions identity is the exceptional unsafe path, so its own token must + # prove that main-merge-writers never lets it update main. + - name: Verify auto-merge identity + if: github.event_name == 'pull_request' && github.event.pull_request.draft == false + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }} + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullNumber = context.payload.pull_request.number; + const eventHeadSha = context.payload.pull_request.head.sha; + const eventBaseSha = context.payload.pull_request.base.sha; + const appSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim(); + if ( + !appSlug || + appSlug !== appSlug.toLowerCase() || + appSlug === 'github-actions' + ) { + core.setFailed( + 'Reviewer Router App slug repository variable is missing or unsafe.', + ); + return; + } + const expectedAppOwner = `${appSlug}[bot]`; + const writerRulesetName = 'main-merge-writers'; + const skipWorkflowPattern = + /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i; + const {data: repository} = await github.rest.repos.get({owner, repo}); + if ( + repository.merge_commit_title !== 'MERGE_MESSAGE' || + !['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message) + ) { + core.setFailed( + 'Repository merge-message defaults changed from their reviewed values.', + ); + return; + } + + const appliedRules = await github.paginate( + 'GET /repos/{owner}/{repo}/rules/branches/{branch}', + {owner, repo, branch: 'main', per_page: 100}, + ); + const repositorySource = `${owner}/${repo}`.toLowerCase(); + const applicableRulesetIDs = [ + ...new Set( + appliedRules + .filter(rule => + rule.ruleset_source_type === 'Repository' && + rule.ruleset_source?.toLowerCase() === repositorySource && + Number.isSafeInteger(Number(rule.ruleset_id)) && + Number(rule.ruleset_id) > 0, + ) + .map(rule => Number(rule.ruleset_id)), + ), + ]; + const activeMainRulesets = []; + for (const rulesetID of applicableRulesetIDs) { + const {data: ruleset} = await github.request( + 'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}', + {owner, repo, ruleset_id: rulesetID}, + ); + if ( + ruleset.enforcement !== 'active' || + ruleset.target !== 'branch' || + ruleset.source_type !== 'Repository' || + ruleset.source?.toLowerCase() !== repositorySource + ) { + core.setFailed( + `Applicable repository ruleset ${ruleset.name || rulesetID} is not an active branch ruleset owned by this repository.`, + ); + return; + } + activeMainRulesets.push(ruleset); + } + + const writerRulesets = activeMainRulesets.filter( + ruleset => ruleset.name === writerRulesetName, + ); + if (writerRulesets.length !== 1) { + core.setFailed( + `Expected exactly one active ${writerRulesetName} ruleset on main; found ${writerRulesets.length}.`, + ); + return; + } + const writerRuleset = writerRulesets[0]; + const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; + const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; + if ( + writerIncludes.length !== 1 || + writerIncludes[0] !== 'refs/heads/main' || + writerExcludes.length !== 0 || + writerRuleset.rules?.length !== 1 || + writerRuleset.rules[0].type !== 'update' || + writerRuleset.rules[0].parameters?.update_allows_fetch_and_merge !== false || + writerRuleset.current_user_can_bypass !== 'never' + ) { + core.setFailed( + `${writerRulesetName} must target only refs/heads/main, contain only the strict update rule, and deny this built-in Actions identity any bypass.`, + ); + return; + } + + const maxAttempts = 6; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if ( + currentPull.head.sha !== eventHeadSha || + currentPull.base.sha !== eventBaseSha || + currentPull.state !== 'open' || + currentPull.draft || + currentPull.base.ref !== 'main' + ) { + core.setFailed( + `PR #${pullNumber} state or revision changed before the Test aggregate verified auto-merge identity.`, + ); + return; + } + const mergeTexts = [ + currentPull.title, + currentPull.auto_merge?.commit_title, + currentPull.auto_merge?.commit_message, + ].filter(value => typeof value === 'string'); + if (mergeTexts.some(value => skipWorkflowPattern.test(value))) { + core.setFailed( + `PR #${pullNumber} merge metadata contains a GitHub workflow-skip directive.`, + ); + return; + } + if (!currentPull.auto_merge) { + core.info( + `PR #${pullNumber} has no auto-merge request; protected-main push or closed-event repair remains authoritative.`, + ); + return; + } + const enabledBy = currentPull.auto_merge.enabled_by?.login?.toLowerCase(); + const safeCommitHeadline = `Merge pull request #${pullNumber}`; + const safeCommitBody = + `Merged by the dedicated Reviewer Router GitHub App for PR #${pullNumber}.`; + if ( + enabledBy === expectedAppOwner && + currentPull.auto_merge.commit_title === safeCommitHeadline && + currentPull.auto_merge.commit_message === safeCommitBody + ) { + core.info( + `PR #${pullNumber} auto-merge is owned by the reviewed ${expectedAppOwner} identity with fixed metadata.`, + ); + return; + } + if (attempt < maxAttempts) { + core.info( + `PR #${pullNumber} auto-merge owner or metadata is not the reviewed App value; waiting for Reviewer Router takeover (${attempt}/${maxAttempts}).`, + ); + await new Promise(resolve => setTimeout(resolve, 5000)); + continue; + } + core.setFailed( + `PR #${pullNumber} auto-merge must be null or owned by ${expectedAppOwner} with the reviewed fixed metadata.`, + ); + } + test-darwin: name: Test (macOS auth/keychain) needs: lint @@ -1236,7 +1410,7 @@ jobs: needs: lint if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }} runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 steps: - name: Check out repository uses: actions/checkout@v4 @@ -1371,6 +1545,143 @@ jobs: path: coverage-base.txt retention-days: 1 + # Documentation and release-seal pushes do not change executable coverage, + # but their new main SHA is still a future PR merge base. Promote only an + # exact predecessor cache after independently proving the whole push changed + # metadata paths; fall back to a full authoritative profile on a cold chain. + coverage-main-metadata: + name: Coverage (main metadata cache) + needs: lint + if: ${{ github.event_name == 'push' && (needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true') }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out exact metadata-only main revision + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + + - name: Set up Go + id: setup-go-metadata + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Verify metadata-only main successor + shell: bash + env: + PUSH_BEFORE_SHA: ${{ github.event.before }} + PUSH_AFTER_SHA: ${{ github.event.after }} + run: | + set -euo pipefail + full_commit='^[0-9a-f]{40}$' + [[ "$PUSH_BEFORE_SHA" =~ $full_commit ]] + [[ "$PUSH_AFTER_SHA" =~ $full_commit ]] + test "$PUSH_BEFORE_SHA" != 0000000000000000000000000000000000000000 + test "$PUSH_AFTER_SHA" = "$GITHUB_SHA" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + git rev-parse --verify "${PUSH_BEFORE_SHA}^{commit}" >/dev/null + git merge-base --is-ancestor "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA" + + changed_count=0 + while IFS= read -r -d '' path; do + changed_count=$((changed_count + 1)) + case "$path" in + CHANGELOG.md|README.md|README_zh.md|CONTRIBUTING.md|SECURITY.md|CODE_OF_CONDUCT.md|LICENSE|NOTICE|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|docs/*) + ;; + .changes/*) + if [[ "$path" =~ ^\.changes/[a-z0-9][a-z0-9._-]*\.md$ ]] || + [[ "$path" =~ ^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$ ]]; then + continue + fi + echo "Refusing coverage-cache promotion for unreviewed change-fragment path: $path" >&2 + exit 1 + ;; + *) + echo "Refusing coverage-cache promotion for executable path: $path" >&2 + exit 1 + ;; + esac + done < <(git diff --name-only --no-renames -z "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA") + test "$changed_count" -gt 0 + echo "COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA" >> "$GITHUB_ENV" + + - name: Restore existing current-SHA coverage profile + id: metadata-current-cache + uses: actions/cache/restore@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }} + + - name: Validate existing current-SHA coverage profile + if: steps.metadata-current-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Restore exact predecessor coverage profile + id: metadata-source-cache + if: steps.metadata-current-cache.outputs.cache-hit != 'true' + uses: actions/cache/restore@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }} + + - name: Validate promoted predecessor coverage profile + if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Install archive tooling for cold metadata baseline + if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true' + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi + + - name: Recompute cold metadata baseline + if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true' + env: + DWS_PACKAGE_VERSION: 0.0.0-test + run: | + set -euo pipefail + go test -count=1 -p 1 \ + -coverprofile=coverage-cache.txt \ + -covermode=atomic \ + ./ ./cmd/... ./internal/... ./skills/... + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Save metadata main SHA coverage profile + if: steps.metadata-current-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }} + + # actions/cache/save reports upload failures as warnings. Convert an + # absent exact target key into a hard producer failure. + - name: Verify metadata main SHA coverage cache exists + id: metadata-target-cache-verification + uses: actions/cache/restore@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }} + lookup-only: true + fail-on-cache-miss: true + + - name: Require exact metadata main SHA coverage cache + env: + EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }} + run: test "$EXACT_CACHE_HIT" = true + coverage: name: Coverage needs: @@ -1379,6 +1690,7 @@ jobs: - coverage-current-full - coverage-supporting - coverage-baseline + - coverage-main-metadata - coverage-darwin - coverage-windows if: ${{ always() && needs.lint.result == 'success' }} @@ -1395,6 +1707,7 @@ jobs: CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }} SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }} BASELINE_RESULT: ${{ needs.coverage-baseline.result }} + MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.result }} DARWIN_RESULT: ${{ needs.coverage-darwin.result }} WINDOWS_RESULT: ${{ needs.coverage-windows.result }} run: | @@ -1403,10 +1716,14 @@ jobs: current_full_expected=skipped supporting_expected=skipped baseline_expected=success + main_metadata_expected=skipped native_expected=skipped if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then current_expected=skipped baseline_expected=skipped + if [ "$GITHUB_EVENT_NAME" = push ]; then + main_metadata_expected=success + fi elif [ "$FULL_SUITE" = true ]; then current_expected=skipped current_full_expected=success @@ -1422,7 +1739,8 @@ jobs: "current:$CURRENT_RESULT:$current_expected" \ "current shards:$CURRENT_FULL_RESULT:$current_full_expected" \ "supporting:$SUPPORTING_RESULT:$supporting_expected" \ - "baseline:$BASELINE_RESULT:$baseline_expected" + "baseline:$BASELINE_RESULT:$baseline_expected" \ + "main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_expected" do name="${profile%%:*}" remainder="${profile#*:}" @@ -1562,6 +1880,22 @@ jobs: path: coverage-cache.txt key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }} + - name: Verify push coverage cache exists + id: push-cache-verification + if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' + uses: actions/cache/restore@v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }} + lookup-only: true + fail-on-cache-miss: true + + - name: Require exact push coverage cache + if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true' + env: + EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }} + run: test "$EXACT_CACHE_HIT" = true + - name: Generate coverage report if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' run: | diff --git a/.github/workflows/coverage-baseline-promotion.yml b/.github/workflows/coverage-baseline-promotion.yml new file mode 100644 index 00000000..6cff727e --- /dev/null +++ b/.github/workflows/coverage-baseline-promotion.yml @@ -0,0 +1,322 @@ +name: Coverage Baseline Promotion + +run-name: Promote coverage baseline for ${{ github.event.client_payload.target_sha }} + +on: + repository_dispatch: + types: [coverage-baseline-promote] + +# repository_dispatch loads this workflow from the protected default branch. +# The requested target is treated as untrusted input until the validation step +# proves it is an exact Formula-only successor already contained in main. +permissions: + checks: write + contents: read + +concurrency: + group: coverage-baseline-promotion-${{ github.event.client_payload.target_sha }} + cancel-in-progress: false + queue: max + +jobs: + promote: + if: github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Validate Formula-only main target + id: validate-target + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const targetSha = context.payload.client_payload?.target_sha; + const sourceRunId = context.payload.client_payload?.source_run_id; + const checkRunId = Number(context.payload.client_payload?.check_run_id); + if (!/^[0-9a-f]{40}$/.test(targetSha || '')) { + throw new Error('coverage-baseline-promote requires one full target_sha'); + } + if (!/^[1-9][0-9]*$/.test(sourceRunId || '')) { + throw new Error('coverage-baseline-promote requires one source_run_id'); + } + if (!Number.isSafeInteger(checkRunId) || checkRunId <= 0) { + throw new Error('coverage-baseline-promote requires one safe check_run_id'); + } + + // Bind the finalizer before any target or cache validation. A + // later failure must complete the release-created acknowledgement + // instead of leaving Release to poll a permanently queued check. + const promotionExternalId = `release-${sourceRunId}-${targetSha}`; + const {data: promotionCheck} = await github.rest.checks.get({ + owner, + repo, + check_run_id: checkRunId, + }); + if ( + promotionCheck.id !== checkRunId || + promotionCheck.head_sha !== targetSha || + promotionCheck.name !== 'Coverage Baseline Cache' || + promotionCheck.external_id !== promotionExternalId || + promotionCheck.app?.slug !== 'github-actions' || + promotionCheck.status !== 'queued' || + promotionCheck.conclusion !== null + ) { + throw new Error('coverage baseline acknowledgement has an invalid identity'); + } + core.setOutput('target_sha', targetSha); + core.setOutput('check_run_id', String(checkRunId)); + core.setOutput('check_external_id', promotionExternalId); + + const {data: targetCommit} = await github.rest.repos.getCommit({ + owner, + repo, + ref: targetSha, + per_page: 100, + }); + const files = targetCommit.files || []; + const message = targetCommit.commit.message; + const formulaPath = files[0]?.filename; + const stableFormula = + formulaPath === 'Formula/dingtalk-workspace-cli.rb' && + /^chore: update formula for v[0-9]+\.[0-9]+\.[0-9]+ \[skip ci\]$/.test(message); + const betaFormula = + formulaPath === 'Formula/dingtalk-workspace-cli-beta.rb' && + /^chore: update beta formula for v[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]* \[skip ci\]$/.test(message); + if ( + targetCommit.sha !== targetSha || + targetCommit.parents.length !== 1 || + targetCommit.author?.login !== 'github-actions[bot]' || + targetCommit.committer?.login !== 'github-actions[bot]' || + files.length !== 1 || + !['added', 'modified'].includes(files[0].status) || + (!stableFormula && !betaFormula) + ) { + throw new Error( + `${targetSha} is not an exact release-produced Formula-only commit`, + ); + } + + const parentSha = targetCommit.parents[0].sha; + const requiredContexts = [ + 'Lint', + 'Test', + 'Coverage', + 'Policy', + 'Edition', + 'Interface Integrity', + 'AI Behavior', + 'CLI Smoke', + 'Mock MCP', + ]; + async function requireSuccessfulAdmission(ref, label) { + for (let attempt = 1; attempt <= 6; attempt += 1) { + const runs = await github.paginate(github.rest.checks.listForRef, { + owner, + repo, + ref, + filter: 'latest', + per_page: 100, + }); + const latestByName = new Map(); + for (const run of runs) { + if ( + run.head_sha !== ref || + run.app?.slug !== 'github-actions' || + !requiredContexts.includes(run.name) + ) { + continue; + } + const current = latestByName.get(run.name); + if (!current || run.id > current.id) { + latestByName.set(run.name, run); + } + } + const invalid = requiredContexts.filter((name) => { + const run = latestByName.get(name); + return !run || run.conclusion !== 'success'; + }); + if (invalid.length === 0) { + return; + } + if (attempt < 6) { + await new Promise(resolve => setTimeout(resolve, 5000)); + continue; + } + throw new Error( + `${label} ${ref} lacks successful Code Admission contexts: ${invalid.join(', ')}`, + ); + } + } + await requireSuccessfulAdmission(parentSha, 'Formula parent'); + await requireSuccessfulAdmission(targetSha, 'Formula target'); + + const {data: branch} = await github.rest.repos.getBranch({ + owner, + repo, + branch: context.payload.repository.default_branch, + }); + const {data: containment} = + await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${targetSha}...${branch.commit.sha}`, + }); + if (!['ahead', 'identical'].includes(containment.status)) { + throw new Error(`${targetSha} is not contained in the protected default branch`); + } + + core.setOutput('parent_sha', parentSha); + core.setOutput('formula_path', formulaPath); + + - name: Mark Formula cache promotion in progress + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + await github.rest.checks.update({ + ...context.repo, + check_run_id: Number('${{ steps.validate-target.outputs.check_run_id }}'), + status: 'in_progress', + started_at: new Date().toISOString(), + output: { + title: 'Producing exact-SHA coverage baseline', + summary: 'The trusted default-branch workflow is validating or producing the main-scoped cache.', + }, + }); + + - name: Check out validated Formula-only target + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.validate-target.outputs.target_sha }} + + - name: Verify checked-out Formula-only identity + shell: bash + env: + TARGET_SHA: ${{ steps.validate-target.outputs.target_sha }} + PARENT_SHA: ${{ steps.validate-target.outputs.parent_sha }} + FORMULA_PATH: ${{ steps.validate-target.outputs.formula_path }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$TARGET_SHA" + test "$(git rev-parse HEAD^)" = "$PARENT_SHA" + test "$(git diff --name-only --no-renames "$PARENT_SHA" "$TARGET_SHA")" = "$FORMULA_PATH" + + - name: Set up Go + id: setup-go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + + - name: Restore existing target coverage profile + id: target-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + + - name: Validate existing target coverage profile + if: steps.target-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Restore exact Formula parent coverage profile + id: parent-cache + if: steps.target-cache.outputs.cache-hit != 'true' + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.validate-target.outputs.parent_sha }}-go${{ steps.setup-go.outputs.go-version }} + + - name: Validate promoted Formula parent profile + if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Install archive tooling for cold Formula baseline + if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true' + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi + + - name: Recompute cold Formula baseline + if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true' + env: + DWS_PACKAGE_VERSION: 0.0.0-test + run: | + set -euo pipefail + go test -count=1 -p 1 \ + -coverprofile=coverage-cache.txt \ + -covermode=atomic \ + ./ ./cmd/... ./internal/... ./skills/... + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Save Formula main SHA coverage profile + if: steps.target-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + + - name: Verify Formula main SHA coverage cache exists + id: formula-target-cache-verification + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + lookup-only: true + fail-on-cache-miss: true + + - name: Require exact Formula main SHA coverage cache + env: + EXACT_CACHE_HIT: ${{ steps.formula-target-cache-verification.outputs.cache-hit }} + run: test "$EXACT_CACHE_HIT" = true + + - name: Complete Formula cache promotion acknowledgement + if: ${{ always() && steps.validate-target.outputs.check_run_id != '' }} + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + PROMOTION_JOB_STATUS: ${{ job.status }} + with: + script: | + const checkRunId = Number('${{ steps.validate-target.outputs.check_run_id }}'); + const targetSha = '${{ steps.validate-target.outputs.target_sha }}'; + const expectedExternalId = '${{ steps.validate-target.outputs.check_external_id }}'; + const {data: currentCheck} = await github.rest.checks.get({ + ...context.repo, + check_run_id: checkRunId, + }); + if ( + currentCheck.head_sha !== targetSha || + currentCheck.name !== 'Coverage Baseline Cache' || + currentCheck.external_id !== expectedExternalId || + currentCheck.app?.slug !== 'github-actions' + ) { + throw new Error('refusing to update a changed promotion acknowledgement'); + } + const succeeded = process.env.PROMOTION_JOB_STATUS === 'success'; + await github.rest.checks.update({ + ...context.repo, + check_run_id: checkRunId, + status: 'completed', + conclusion: succeeded ? 'success' : 'failure', + completed_at: new Date().toISOString(), + output: { + title: succeeded + ? 'Exact-SHA coverage baseline is available' + : 'Exact-SHA coverage baseline promotion failed', + summary: succeeded + ? `Verified the main-scoped exact cache for ${targetSha}.` + : `Promotion failed for ${targetSha}; rerun the failed Release job after correcting the producer.`, + }, + }); diff --git a/.github/workflows/coverage-baseline-repair.yml b/.github/workflows/coverage-baseline-repair.yml new file mode 100644 index 00000000..23190ae5 --- /dev/null +++ b/.github/workflows/coverage-baseline-repair.yml @@ -0,0 +1,529 @@ +name: Coverage Baseline Repair + +run-name: Repair coverage baseline from ${{ github.event_name }} + +on: + pull_request_target: + branches: [main] + types: [closed] + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + repository_dispatch: + types: [coverage-baseline-repair] + schedule: + - cron: "23 * * * *" + workflow_dispatch: + +# pull_request_target and workflow_run are allowed to inspect only GitHub API +# data and dispatch the trusted producer. GitHub deliberately makes both +# triggers read-only for the default-branch cache, so all checkout and cache +# writes live in repository_dispatch, schedule, or main-only workflow_dispatch. +permissions: + contents: read + +concurrency: + group: coverage-baseline-repair-${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'repository_dispatch' && github.event.client_payload.merge_commit_sha || github.sha }} + cancel-in-progress: false + # Retain every pending repair for one target. actionlint v1.7.12's bundled + # schema predates GitHub's concurrency.queue support. + queue: max + +jobs: + dispatch-merged-pr: + if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: write + pull-requests: read + steps: + # Never check out or execute pull-request content in this privileged + # base-owned event. Re-read the merged PR, bind every immutable identity, + # prove the result is in main, and send only those values to the producer. + - name: Dispatch trusted merged-PR repair + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const eventPull = context.payload.pull_request; + const fullCommit = /^[0-9a-f]{40}$/; + const pullNumber = Number(eventPull?.number); + const headSha = eventPull?.head?.sha; + const baseSha = eventPull?.base?.sha; + const mergeCommitSha = eventPull?.merge_commit_sha; + if ( + context.payload.repository?.full_name !== + 'DingTalk-Real-AI/dingtalk-workspace-cli' || + context.payload.repository?.default_branch !== 'main' || + !Number.isSafeInteger(pullNumber) || + pullNumber <= 0 || + !fullCommit.test(headSha || '') || + !fullCommit.test(baseSha || '') || + !fullCommit.test(mergeCommitSha || '') + ) { + throw new Error('closed PR event has an invalid repository or revision identity'); + } + + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if ( + currentPull.number !== pullNumber || + currentPull.state !== 'closed' || + currentPull.merged !== true || + !currentPull.merged_at || + currentPull.base.ref !== 'main' || + currentPull.base.sha !== baseSha || + currentPull.head.sha !== headSha || + currentPull.merge_commit_sha !== mergeCommitSha + ) { + throw new Error(`PR #${pullNumber} no longer matches the merged-main event`); + } + + async function requireMainContainment(targetSha) { + let lastState = 'not checked'; + for (let attempt = 1; attempt <= 6; attempt += 1) { + try { + const {data: branch} = await github.rest.repos.getBranch({ + owner, + repo, + branch: 'main', + }); + const {data: comparison} = + await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${targetSha}...${branch.commit.sha}`, + }); + lastState = comparison.status; + if (['ahead', 'identical'].includes(comparison.status)) { + return; + } + } catch (error) { + lastState = error.message; + } + if (attempt < 6) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + throw new Error( + `${targetSha} is not contained in protected main after retries: ${lastState}`, + ); + } + await requireMainContainment(mergeCommitSha); + + // Normal App or human merges emit a protected-main push run whose + // CI producer owns this exact key. Give Actions event delivery a + // short visibility window and avoid a duplicate full-suite repair. + // A workflow-skip directive or suppressed built-in-token event has + // no such run, so only that missing-event path reaches dispatch. + const {data: ciWorkflow} = await github.rest.actions.getWorkflow({ + owner, + repo, + workflow_id: '.github/workflows/ci.yml', + }); + if ( + ciWorkflow.name !== 'CI' || + ciWorkflow.path !== '.github/workflows/ci.yml' || + ciWorkflow.state !== 'active' + ) { + throw new Error('protected CI workflow identity is not active or exact'); + } + for (let attempt = 1; attempt <= 12; attempt += 1) { + const {data: workflowRuns} = + await github.rest.actions.listWorkflowRunsForRepo({ + owner, + repo, + branch: 'main', + event: 'push', + per_page: 100, + }); + const exactPushRun = workflowRuns.workflow_runs.find(run => + run.name === 'CI' && + run.workflow_id === ciWorkflow.id && + run.path === ciWorkflow.path && + run.event === 'push' && + run.head_sha === mergeCommitSha && + run.head_branch === 'main' && + ['queued', 'in_progress', 'completed'].includes(run.status), + ); + if (exactPushRun) { + core.info( + `CI push run ${exactPushRun.id} already owns the exact-SHA producer for ${mergeCommitSha}; repair dispatch is unnecessary.`, + ); + return; + } + if (attempt < 12) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + + // repository_dispatch is one of GitHub's explicit GITHUB_TOKEN + // recursion exceptions and receives default-branch cache-write scope. + await github.rest.repos.createDispatchEvent({ + owner, + repo, + event_type: 'coverage-baseline-repair', + client_payload: { + source: 'merged_pr', + pull_number: String(pullNumber), + head_sha: headSha, + base_sha: baseSha, + merge_commit_sha: mergeCommitSha, + source_run_id: String(context.runId), + }, + }); + core.info( + `Dispatched exact-SHA coverage repair for merged PR #${pullNumber} at ${mergeCommitSha}.`, + ); + + dispatch-failed-ci: + if: >- + ${{ + github.event_name == 'workflow_run' && + github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' && + github.event.workflow_run.name == 'CI' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' && + github.event.workflow_run.status == 'completed' && + github.event.workflow_run.conclusion != 'success' + }} + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: write + steps: + # workflow_run cannot write the default-branch cache. Re-read the exact + # completed CI run from Actions, bind it to the protected CI workflow and + # main revision, then use the repository_dispatch recursion exception. + - name: Dispatch trusted failed-CI repair + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const upstream = 'DingTalk-Real-AI/dingtalk-workspace-cli'; + const eventRun = context.payload.workflow_run; + const fullCommit = /^[0-9a-f]{40}$/; + const runID = Number(eventRun?.id); + const runAttempt = Number(eventRun?.run_attempt); + const headSha = eventRun?.head_sha; + const conclusion = eventRun?.conclusion; + if ( + context.payload.repository?.full_name !== upstream || + context.payload.repository?.default_branch !== 'main' || + !Number.isSafeInteger(runID) || + runID <= 0 || + !Number.isSafeInteger(runAttempt) || + runAttempt <= 0 || + eventRun?.name !== 'CI' || + eventRun?.event !== 'push' || + eventRun?.head_branch !== 'main' || + eventRun?.status !== 'completed' || + typeof conclusion !== 'string' || + conclusion.length === 0 || + conclusion === 'success' || + !fullCommit.test(headSha || '') + ) { + throw new Error('workflow_run event is not one completed non-success main CI push'); + } + + const {data: ciWorkflow} = await github.rest.actions.getWorkflow({ + owner, + repo, + workflow_id: '.github/workflows/ci.yml', + }); + const {data: currentRun} = await github.rest.actions.getWorkflowRun({ + owner, + repo, + run_id: runID, + }); + if ( + ciWorkflow.name !== 'CI' || + ciWorkflow.path !== '.github/workflows/ci.yml' || + eventRun.workflow_id !== ciWorkflow.id || + currentRun.id !== runID || + currentRun.workflow_id !== ciWorkflow.id || + currentRun.name !== 'CI' || + currentRun.event !== 'push' || + currentRun.head_branch !== 'main' || + currentRun.head_sha !== headSha || + currentRun.run_attempt !== runAttempt || + currentRun.status !== 'completed' || + currentRun.conclusion !== conclusion || + currentRun.conclusion === 'success' || + currentRun.repository?.full_name !== upstream || + currentRun.head_repository?.full_name !== upstream + ) { + throw new Error(`CI workflow run ${runID} no longer matches the completed event`); + } + + await github.rest.repos.createDispatchEvent({ + owner, + repo, + event_type: 'coverage-baseline-repair', + client_payload: { + source: 'failed_ci', + workflow_run_id: String(runID), + workflow_run_attempt: String(runAttempt), + workflow_conclusion: conclusion, + merge_commit_sha: headSha, + source_run_id: String(context.runId), + }, + }); + core.info( + `Dispatched exact-SHA coverage repair for ${conclusion} CI run ${runID} at ${headSha}.`, + ); + + repair: + if: ${{ github.event_name == 'repository_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + actions: read + contents: read + pull-requests: read + steps: + - name: Resolve trusted main repair target + id: resolve-target + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const fullCommit = /^[0-9a-f]{40}$/; + if ( + context.payload.repository?.full_name !== + 'DingTalk-Real-AI/dingtalk-workspace-cli' || + context.payload.repository?.default_branch !== 'main' + ) { + throw new Error('coverage repair is restricted to the protected upstream repository'); + } + + async function requireMainContainment(targetSha) { + let lastState = 'not checked'; + for (let attempt = 1; attempt <= 6; attempt += 1) { + try { + const {data: branch} = await github.rest.repos.getBranch({ + owner, + repo, + branch: 'main', + }); + const {data: comparison} = + await github.rest.repos.compareCommitsWithBasehead({ + owner, + repo, + basehead: `${targetSha}...${branch.commit.sha}`, + }); + lastState = comparison.status; + if (['ahead', 'identical'].includes(comparison.status)) { + return branch.commit.sha; + } + } catch (error) { + lastState = error.message; + } + if (attempt < 6) { + await new Promise(resolve => setTimeout(resolve, 5000)); + } + } + throw new Error( + `${targetSha} is not contained in protected main after retries: ${lastState}`, + ); + } + + let targetSha; + if (context.eventName === 'repository_dispatch') { + const payload = context.payload.client_payload || {}; + const sourceRunIDText = String(payload.source_run_id || ''); + if (!/^[1-9][0-9]*$/.test(sourceRunIDText)) { + throw new Error('coverage-baseline-repair payload has an invalid source run'); + } + if (payload.source === 'merged_pr') { + const rawPullNumber = String(payload.pull_number || ''); + const pullNumber = Number(rawPullNumber); + const headSha = payload.head_sha; + const baseSha = payload.base_sha; + targetSha = payload.merge_commit_sha; + if ( + !/^[1-9][0-9]*$/.test(rawPullNumber) || + !Number.isSafeInteger(pullNumber) || + !fullCommit.test(headSha || '') || + !fullCommit.test(baseSha || '') || + !fullCommit.test(targetSha || '') + ) { + throw new Error('coverage-baseline-repair payload has an invalid PR identity'); + } + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if ( + currentPull.number !== pullNumber || + currentPull.state !== 'closed' || + currentPull.merged !== true || + !currentPull.merged_at || + currentPull.base.ref !== 'main' || + currentPull.base.sha !== baseSha || + currentPull.head.sha !== headSha || + currentPull.merge_commit_sha !== targetSha + ) { + throw new Error( + `repair payload no longer matches merged PR #${pullNumber}`, + ); + } + } else if (payload.source === 'failed_ci') { + const rawWorkflowRunID = String(payload.workflow_run_id || ''); + const workflowRunID = Number(rawWorkflowRunID); + const rawWorkflowRunAttempt = String(payload.workflow_run_attempt || ''); + const workflowRunAttempt = Number(rawWorkflowRunAttempt); + const workflowConclusion = payload.workflow_conclusion; + targetSha = payload.merge_commit_sha; + if ( + !/^[1-9][0-9]*$/.test(rawWorkflowRunID) || + !Number.isSafeInteger(workflowRunID) || + !/^[1-9][0-9]*$/.test(rawWorkflowRunAttempt) || + !Number.isSafeInteger(workflowRunAttempt) || + typeof workflowConclusion !== 'string' || + workflowConclusion.length === 0 || + workflowConclusion === 'success' || + !fullCommit.test(targetSha || '') + ) { + throw new Error('coverage-baseline-repair payload has an invalid CI identity'); + } + const {data: ciWorkflow} = await github.rest.actions.getWorkflow({ + owner, + repo, + workflow_id: '.github/workflows/ci.yml', + }); + const {data: currentRun} = await github.rest.actions.getWorkflowRun({ + owner, + repo, + run_id: workflowRunID, + }); + if ( + ciWorkflow.name !== 'CI' || + ciWorkflow.path !== '.github/workflows/ci.yml' || + currentRun.id !== workflowRunID || + currentRun.workflow_id !== ciWorkflow.id || + currentRun.name !== 'CI' || + currentRun.event !== 'push' || + currentRun.head_branch !== 'main' || + currentRun.head_sha !== targetSha || + currentRun.run_attempt !== workflowRunAttempt || + currentRun.status !== 'completed' || + currentRun.conclusion !== workflowConclusion || + currentRun.conclusion === 'success' || + currentRun.repository?.full_name !== + 'DingTalk-Real-AI/dingtalk-workspace-cli' || + currentRun.head_repository?.full_name !== + 'DingTalk-Real-AI/dingtalk-workspace-cli' + ) { + throw new Error( + `repair payload no longer matches failed CI run ${workflowRunID}`, + ); + } + } else { + throw new Error('coverage-baseline-repair payload has an unknown source'); + } + await requireMainContainment(targetSha); + } else { + if (context.ref !== 'refs/heads/main') { + throw new Error('scheduled and manual repair must run from refs/heads/main'); + } + // github.sha is the default-branch tip that keyed this workflow's + // concurrency group. Keep the producer bound to that exact + // event-time target even if main advances while this run queues. + targetSha = context.sha; + if (!fullCommit.test(targetSha || '')) { + throw new Error('protected main did not resolve to one full commit SHA'); + } + await requireMainContainment(targetSha); + } + core.setOutput('target_sha', targetSha); + core.info(`Resolved protected-main coverage repair target ${targetSha}.`); + + - name: Check out exact protected-main target + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.resolve-target.outputs.target_sha }} + + - name: Verify checked-out repair target + env: + TARGET_SHA: ${{ steps.resolve-target.outputs.target_sha }} + run: test "$(git rev-parse HEAD)" = "$TARGET_SHA" + + - name: Set up Go + id: setup-go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + + - name: Restore exact target coverage profile + id: target-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + + - name: Validate existing exact target profile + if: steps.target-cache.outputs.cache-hit == 'true' + run: | + set -eu + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Install archive tooling for cold repair + if: steps.target-cache.outputs.cache-hit != 'true' + run: | + if command -v zip >/dev/null && command -v unzip >/dev/null; then + echo "zip and unzip are already available" + else + sudo apt-get update + sudo apt-get install -y zip unzip + fi + + - name: Recompute complete target coverage profile + if: steps.target-cache.outputs.cache-hit != 'true' + env: + DWS_PACKAGE_VERSION: 0.0.0-test + run: | + set -euo pipefail + go test -count=1 -p 1 \ + -coverprofile=coverage-cache.txt \ + -covermode=atomic \ + ./ ./cmd/... ./internal/... ./skills/... + test -s coverage-cache.txt + test "$(head -n 1 coverage-cache.txt)" = "mode: atomic" + + - name: Save exact protected-main coverage profile + if: steps.target-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + + # Cache uploads are fail-open warnings. A lookup-only restore plus the + # explicit cache-hit assertion makes an absent or partial key fail hard. + - name: Verify exact protected-main coverage cache exists + id: target-cache-verification + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: coverage-cache.txt + key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }} + lookup-only: true + fail-on-cache-miss: true + + - name: Require exact protected-main coverage cache + env: + EXACT_CACHE_HIT: ${{ steps.target-cache-verification.outputs.cache-hit }} + run: test "$EXACT_CACHE_HIT" = true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 774495a5..89f38b3a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1112,6 +1112,9 @@ jobs: needs: [release-contract, release-validation, release, verify-darwin-signatures] runs-on: ubuntu-latest timeout-minutes: 30 + outputs: + coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }} + coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }} permissions: checks: write contents: write @@ -1495,6 +1498,7 @@ jobs: DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com - name: Seal Formula-only Code Admission contexts + id: seal-formula if: ${{ github.repository_owner == 'DingTalk-Real-AI' }} uses: actions/github-script@v7 env: @@ -1515,6 +1519,8 @@ jobs: const sourcePath = channel === "stable" ? "dist/homebrew/dingtalk-workspace-cli.rb" : "dist/homebrew/dingtalk-workspace-cli-beta.rb"; + core.setOutput("coverage_baseline_required", "false"); + core.setOutput("coverage_baseline_commit", ""); const expectedMessage = channel === "stable" ? `chore: update formula for ${version} [skip ci]` : `chore: update beta formula for ${version} [skip ci]`; @@ -1650,6 +1656,11 @@ jobs: }, }); } + core.setOutput("coverage_baseline_required", "true"); + core.setOutput("coverage_baseline_commit", commit); + core.info( + `Formula-only Code Admission is sealed for ${commit}; the independent confirmation job will dispatch its exact-SHA cache producer.`, + ); - name: Reverify exact immutable npm package run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION" @@ -2177,6 +2188,117 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + coverage-baseline-confirmation: + name: Confirm Formula coverage baseline + # Once Formula sealing has exposed a target SHA, later publication + # verification failures must not orphan its exact-main cache producer. + if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }} + needs: publish-release + runs-on: ubuntu-latest + timeout-minutes: 35 + permissions: + checks: write + contents: write + steps: + - name: Require exact Formula cache acknowledgement + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }} + FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }} + with: + script: | + const rawRequired = process.env.BASELINE_REQUIRED; + if (!['true', 'false'].includes(rawRequired)) { + throw new Error(`Formula baseline requirement is invalid: ${rawRequired || 'empty'}`); + } + const required = rawRequired === 'true'; + const targetSha = process.env.FORMULA_COMMIT; + if (!required) { + if (targetSha) { + throw new Error('Formula baseline outputs are inconsistent for a no-op publication'); + } + core.info('Formula was already current; no new exact-SHA cache acknowledgement is required.'); + return; + } + if (!/^[0-9a-f]{40}$/.test(targetSha)) { + throw new Error('Formula baseline target output is malformed'); + } + const expectedExternalId = `release-${context.runId}-${targetSha}`; + let promotionCheck; + try { + const created = await github.rest.checks.create({ + ...context.repo, + name: 'Coverage Baseline Cache', + head_sha: targetSha, + status: 'queued', + external_id: expectedExternalId, + output: { + title: 'Waiting for exact-SHA baseline promotion', + summary: + 'The independent release governance job is waiting for the default-branch cache producer.', + }, + }); + promotionCheck = created.data; + await github.rest.repos.createDispatchEvent({ + ...context.repo, + event_type: 'coverage-baseline-promote', + client_payload: { + target_sha: targetSha, + source_run_id: String(context.runId), + check_run_id: String(promotionCheck.id), + }, + }); + } catch (error) { + if (promotionCheck) { + try { + await github.rest.checks.update({ + ...context.repo, + check_run_id: promotionCheck.id, + status: 'completed', + conclusion: 'failure', + completed_at: new Date().toISOString(), + output: { + title: 'Coverage baseline dispatch failed', + summary: `Release could not dispatch the exact-SHA producer: ${error.message}`, + }, + }); + } catch (cleanupError) { + core.error( + `Could not close failed cache acknowledgement ${promotionCheck.id}: ${cleanupError.message}`, + ); + } + } + throw error; + } + const checkRunId = promotionCheck.id; + for (let attempt = 1; attempt <= 180; attempt += 1) { + const {data: currentCheck} = await github.rest.checks.get({ + ...context.repo, + check_run_id: checkRunId, + }); + if ( + currentCheck.head_sha !== targetSha || + currentCheck.name !== 'Coverage Baseline Cache' || + currentCheck.external_id !== expectedExternalId || + currentCheck.app?.slug !== 'github-actions' + ) { + throw new Error('Formula baseline promotion acknowledgement changed identity'); + } + if (currentCheck.status === 'completed') { + if (currentCheck.conclusion !== 'success') { + throw new Error( + `Formula baseline promotion failed with ${currentCheck.conclusion || 'unknown'}`, + ); + } + core.info(`Formula baseline promotion completed for ${targetSha}.`); + return; + } + if (attempt < 180) { + await new Promise(resolve => setTimeout(resolve, 10000)); + } + } + throw new Error(`Formula baseline promotion timed out for ${targetSha}`); + release-delivery-gate: name: Release delivery gate if: ${{ !cancelled() }} @@ -2189,6 +2311,7 @@ jobs: - verify-darwin-signatures - publish-release - publish-channels + - coverage-baseline-confirmation - mirror-gitee-release - repair-npm - repair-channel @@ -2211,6 +2334,7 @@ jobs: DARWIN_SIGNATURE_RESULT: ${{ needs.verify-darwin-signatures.result }} PUBLISH_RELEASE_RESULT: ${{ needs.publish-release.result }} PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }} + COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }} MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }} REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }} REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }} @@ -2234,6 +2358,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success require_result publish-release "$PUBLISH_RELEASE_RESULT" success require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" success if test "$GITEE_FALLBACK_ENABLED" = true; then require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" success else @@ -2273,6 +2398,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped @@ -2294,6 +2420,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped @@ -2309,6 +2436,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped require_cloud_jobs_skipped @@ -2323,6 +2451,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_cloud_jobs_skipped @@ -2337,6 +2466,7 @@ jobs: require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped + require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped require_result repair-npm "$REPAIR_NPM_RESULT" skipped require_cloud_jobs_skipped diff --git a/.github/workflows/reviewer-router.yml b/.github/workflows/reviewer-router.yml index 105ac713..7e33568a 100644 --- a/.github/workflows/reviewer-router.yml +++ b/.github/workflows/reviewer-router.yml @@ -3,21 +3,24 @@ name: Reviewer routing on: pull_request_target: branches: [main] - types: [opened, synchronize, reopened, ready_for_review] + types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled] + push: + branches: [main] + workflow_dispatch: # pull_request_target deliberately runs only this workflow from the protected # base branch. Never check out or execute pull-request code here. permissions: - contents: write + contents: read pull-requests: write concurrency: - group: reviewer-router-${{ github.event.pull_request.number }} - cancel-in-progress: true + group: reviewer-router-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || 'reconcile-main' }} + cancel-in-progress: ${{ github.event_name == 'pull_request_target' }} jobs: route: - if: github.event.pull_request.draft == false + if: github.event_name == 'pull_request_target' && github.event.pull_request.draft == false runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -26,7 +29,7 @@ jobs: with: ref: ${{ github.event.pull_request.base.sha }} persist-credentials: false - - name: Route review and enable auto-merge + - name: Route review uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | @@ -34,6 +37,7 @@ jobs: const repo = context.repo.repo; const pullNumber = context.payload.pull_request.number; const eventHeadSha = context.payload.pull_request.head.sha; + const eventBaseSha = context.payload.pull_request.base.sha; const { REVIEWER_POOL, requestReviewersWithFallback, @@ -50,6 +54,7 @@ jobs: }); if ( currentPull.head.sha !== eventHeadSha || + currentPull.base.sha !== eventBaseSha || currentPull.state !== 'open' || currentPull.draft || currentPull.base.ref !== 'main' @@ -256,41 +261,6 @@ jobs: } } - async function enableAutoMerge() { - try { - const currentPull = await getReadyEventPull('auto-merge enable'); - if (!currentPull) { - return; - } - if (currentPull.auto_merge) { - core.info(`Auto-merge is already enabled for PR #${pullNumber}.`); - return; - } - await github.graphql( - `mutation EnableAutoMerge($pullRequestId: ID!) { - enablePullRequestAutoMerge( - input: { - pullRequestId: $pullRequestId - mergeMethod: MERGE - } - ) { - pullRequest { - autoMergeRequest { - enabledAt - } - } - } - }`, - {pullRequestId: currentPull.node_id}, - ); - core.info(`Enabled native auto-merge for PR #${pullNumber}.`); - } catch (error) { - core.warning( - `Could not enable auto-merge for PR #${pullNumber}; checks and review can continue normally (${error.message}).`, - ); - } - } - try { await routeReview(); } catch (error) { @@ -298,4 +268,706 @@ jobs: `Reviewer routing hit an unexpected error for PR #${pullNumber}; review can still proceed manually (${error.message}).`, ); } - await enableAutoMerge(); + manage-auto-merge: + if: github.event_name == 'pull_request_target' && github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 5 + # Contents write is isolated to the trusted cleanup job because GitHub + # treats auto-merge management as merge authority. Only the App token is + # ever used to enable auto-merge. + permissions: + contents: write + pull-requests: write + steps: + # Auto-merge must use a dedicated identity. GitHub suppresses most + # workflow events created by the built-in GITHUB_TOKEN, including the + # main push that produces post-merge admission and baseline caches. + # Clear the known-unsafe owner and any workflow-skipping merge request + # before touching App credentials. Missing credentials then fail closed + # with manual merge intact. + - name: Clear unsafe auto-merge request + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullNumber = context.payload.pull_request.number; + const eventHeadSha = context.payload.pull_request.head.sha; + const eventBaseSha = context.payload.pull_request.base.sha; + const unsafeOwner = 'github-actions[bot]'; + const skipWorkflowPattern = + /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i; + + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if ( + currentPull.head.sha !== eventHeadSha || + currentPull.base.sha !== eventBaseSha || + currentPull.state !== 'open' || + currentPull.draft || + currentPull.base.ref !== 'main' + ) { + core.info( + `PR #${pullNumber} state or revision no longer matches this ready-main event; unsafe-owner cleanup stopped.`, + ); + return; + } + + const enabledBy = currentPull.auto_merge?.enabled_by?.login?.toLowerCase(); + const mergeTexts = [ + currentPull.title, + currentPull.auto_merge?.commit_title, + currentPull.auto_merge?.commit_message, + ].filter(value => typeof value === 'string'); + const skipRequested = mergeTexts.some(value => + skipWorkflowPattern.test(value), + ); + if (!currentPull.auto_merge || (enabledBy !== unsafeOwner && !skipRequested)) { + core.info( + currentPull.auto_merge + ? `PR #${pullNumber} auto-merge is owned by ${enabledBy || 'an unknown actor'}; preserving it.` + : `PR #${pullNumber} has no auto-merge request to clear.`, + ); + return; + } + + await github.graphql( + `mutation DisableAutoMerge($pullRequestId: ID!) { + disablePullRequestAutoMerge( + input: {pullRequestId: $pullRequestId} + ) { + pullRequest { + id + } + } + }`, + {pullRequestId: currentPull.node_id}, + ); + const {data: cleanedPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + const cleanedOwner = cleanedPull.auto_merge?.enabled_by?.login?.toLowerCase(); + if ( + cleanedPull.head.sha === eventHeadSha && + cleanedPull.base.sha === eventBaseSha && + cleanedPull.state === 'open' && + !cleanedPull.draft && + cleanedPull.base.ref === 'main' && + cleanedPull.auto_merge + ) { + throw new Error( + `PR #${pullNumber} still has auto-merge owned by ${cleanedOwner || 'an unknown actor'} after unsafe-request cleanup.`, + ); + } + core.info( + skipRequested + ? `Cleared workflow-skipping auto-merge metadata from PR #${pullNumber}.` + : `Cleared built-in GITHUB_TOKEN auto-merge ownership from PR #${pullNumber}.`, + ); + - name: Mint Reviewer Router GitHub App token + id: reviewer-router-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }} + private-key: ${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + - name: Enable auto-merge with dedicated App + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + MINTED_REVIEWER_ROUTER_APP_SLUG: ${{ steps.reviewer-router-token.outputs.app-slug }} + REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }} + with: + github-token: ${{ steps.reviewer-router-token.outputs.token }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const pullNumber = context.payload.pull_request.number; + const eventHeadSha = context.payload.pull_request.head.sha; + const eventBaseSha = context.payload.pull_request.base.sha; + const mintedAppSlug = process.env.MINTED_REVIEWER_ROUTER_APP_SLUG?.trim(); + const appSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim(); + if ( + !appSlug || + appSlug !== appSlug.toLowerCase() || + appSlug === 'github-actions' || + mintedAppSlug !== appSlug + ) { + throw new Error( + 'Minted Reviewer Router App slug does not exactly match the reviewed repository variable.', + ); + } + const expectedAppOwner = `${appSlug}[bot]`; + const repositorySource = `${owner}/${repo}`.toLowerCase(); + const appliedRules = await github.paginate( + 'GET /repos/{owner}/{repo}/rules/branches/{branch}', + {owner, repo, branch: 'main', per_page: 100}, + ); + const applicableRulesetIDs = [ + ...new Set( + appliedRules + .map(rule => Number(rule.ruleset_id)) + .filter(rulesetID => + Number.isSafeInteger(rulesetID) && rulesetID > 0, + ), + ), + ]; + const activeMainRulesets = []; + for (const rulesetID of applicableRulesetIDs) { + const {data: ruleset} = await github.request( + 'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}', + {owner, repo, ruleset_id: rulesetID}, + ); + if ( + ruleset.enforcement !== 'active' || + ruleset.target !== 'branch' + ) { + throw new Error( + `Applicable ruleset ${ruleset.name || rulesetID} is not an active branch ruleset.`, + ); + } + activeMainRulesets.push(ruleset); + } + const writerRulesets = activeMainRulesets.filter( + ruleset => ruleset.name === 'main-merge-writers', + ); + if (writerRulesets.length !== 1) { + throw new Error( + 'Expected exactly one active main-merge-writers ruleset on main.', + ); + } + const writerRuleset = writerRulesets[0]; + const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; + const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; + if ( + writerRuleset.source_type !== 'Repository' || + writerRuleset.source?.toLowerCase() !== repositorySource || + writerIncludes.length !== 1 || + writerIncludes[0] !== 'refs/heads/main' || + writerExcludes.length !== 0 || + writerRuleset.rules?.length !== 1 || + writerRuleset.rules[0].type !== 'update' || + writerRuleset.rules[0].parameters?.update_allows_fetch_and_merge !== false || + writerRuleset.current_user_can_bypass !== 'pull_requests_only' + ) { + throw new Error( + 'Reviewer Router App requires the exact repository-owned main writer rule with pull-request-only bypass.', + ); + } + for (const ruleset of activeMainRulesets) { + if (ruleset.id === writerRuleset.id) { + continue; + } + if (ruleset.current_user_can_bypass !== 'never') { + throw new Error( + `Reviewer Router App must never bypass main ruleset ${ruleset.name || ruleset.id}.`, + ); + } + } + const skipWorkflowPattern = + /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i; + const safeCommitHeadline = `Merge pull request #${pullNumber}`; + const safeCommitBody = + `Merged by the dedicated Reviewer Router GitHub App for PR #${pullNumber}.`; + + async function disableAutoMerge(pull, phase) { + await github.graphql( + `mutation DisableAutoMerge($pullRequestId: ID!) { + disablePullRequestAutoMerge( + input: {pullRequestId: $pullRequestId} + ) { + pullRequest { id } + } + }`, + {pullRequestId: pull.node_id}, + ); + const {data: revertedPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (revertedPull.auto_merge) { + throw new Error( + `PR #${pullNumber} retained auto-merge owned by ${revertedPull.auto_merge.enabled_by?.login || 'an unknown actor'} after ${phase}.`, + ); + } + return revertedPull; + } + + async function getReadyEventPull(phase) { + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if ( + currentPull.head.sha !== eventHeadSha || + currentPull.base.sha !== eventBaseSha || + currentPull.state !== 'open' || + currentPull.draft || + currentPull.base.ref !== 'main' + ) { + core.info( + `PR #${pullNumber} state or revision no longer matches this ready-main event during ${phase}; auto-merge stopped.`, + ); + return null; + } + return currentPull; + } + + let currentPull = await getReadyEventPull('auto-merge enable'); + if (!currentPull) { + return; + } + const mergeTexts = [ + currentPull.title, + currentPull.auto_merge?.commit_title, + currentPull.auto_merge?.commit_message, + ].filter(value => typeof value === 'string'); + if (mergeTexts.some(value => skipWorkflowPattern.test(value))) { + if (currentPull.auto_merge) { + await disableAutoMerge(currentPull, 'workflow-skip cleanup'); + } + throw new Error( + `PR #${pullNumber} merge metadata contains a GitHub workflow-skip directive.`, + ); + } + if (currentPull.auto_merge) { + const existingOwner = currentPull.auto_merge.enabled_by?.login?.toLowerCase(); + if ( + existingOwner === expectedAppOwner && + currentPull.auto_merge.commit_title === safeCommitHeadline && + currentPull.auto_merge.commit_message === safeCommitBody + ) { + core.info( + `PR #${pullNumber} already has the reviewed ${expectedAppOwner} auto-merge request; preserving it.`, + ); + return; + } + await disableAutoMerge( + currentPull, + existingOwner === expectedAppOwner + ? 'unsafe dedicated-App metadata cleanup' + : 'non-dedicated owner cleanup', + ); + currentPull = await getReadyEventPull('post-owner cleanup'); + if (!currentPull) { + return; + } + if (currentPull.auto_merge) { + throw new Error( + `PR #${pullNumber} acquired another auto-merge owner before dedicated-App enable.`, + ); + } + } + await github.graphql( + `mutation EnableAutoMerge( + $pullRequestId: ID! + $expectedHeadOid: GitObjectID! + $commitHeadline: String! + $commitBody: String! + ) { + enablePullRequestAutoMerge( + input: { + pullRequestId: $pullRequestId + mergeMethod: MERGE + expectedHeadOid: $expectedHeadOid + commitHeadline: $commitHeadline + commitBody: $commitBody + } + ) { + pullRequest { + autoMergeRequest { + enabledAt + } + } + } + }`, + { + pullRequestId: currentPull.node_id, + expectedHeadOid: eventHeadSha, + commitHeadline: safeCommitHeadline, + commitBody: safeCommitBody, + }, + ); + const {data: enabledPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + const enabledBy = enabledPull?.auto_merge?.enabled_by?.login?.toLowerCase(); + const exactRevision = + enabledPull.head.sha === eventHeadSha && + enabledPull.base.sha === eventBaseSha && + enabledPull.state === 'open' && + !enabledPull.draft && + enabledPull.base.ref === 'main'; + if (!exactRevision && enabledBy === expectedAppOwner) { + await disableAutoMerge(enabledPull, 'stale-enable cleanup'); + } + if (!exactRevision) { + throw new Error( + `PR #${pullNumber} state or revision changed during dedicated-App enable.`, + ); + } + if (enabledBy !== expectedAppOwner) { + if (enabledPull.auto_merge) { + await disableAutoMerge(enabledPull, 'unexpected owner cleanup'); + } + throw new Error( + `PR #${pullNumber} auto-merge owner ${enabledBy || 'unknown'} does not match ${expectedAppOwner}.`, + ); + } + if ( + enabledPull.auto_merge?.commit_title !== safeCommitHeadline || + enabledPull.auto_merge?.commit_message !== safeCommitBody + ) { + await disableAutoMerge(enabledPull, 'unsafe-merge-message cleanup'); + throw new Error( + `PR #${pullNumber} dedicated-App merge metadata was not preserved exactly.`, + ); + } + core.info( + `Enabled native auto-merge for PR #${pullNumber} with ${enabledBy}.`, + ); + + # Existing ready PRs do not emit a fresh routing event when this workflow is + # deployed. The protected-main push that deploys it migrates every non-App + # request, repairs App requests with unreviewed metadata, and clears + # workflow-skipping merge metadata; workflow_dispatch provides an idempotent + # recovery path. Null and exact safe App requests remain untouched. + reconcile: + if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: {} + steps: + - name: Mint Reviewer Router GitHub App token + id: reviewer-router-reconcile-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }} + private-key: ${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-contents: write + permission-pull-requests: write + - name: Reconcile unsafe auto-merge requests + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 + env: + MINTED_REVIEWER_ROUTER_APP_SLUG: ${{ steps.reviewer-router-reconcile-token.outputs.app-slug }} + REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }} + with: + github-token: ${{ steps.reviewer-router-reconcile-token.outputs.token }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const mintedAppSlug = process.env.MINTED_REVIEWER_ROUTER_APP_SLUG?.trim(); + const appSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim(); + if ( + !appSlug || + appSlug !== appSlug.toLowerCase() || + appSlug === 'github-actions' || + mintedAppSlug !== appSlug + ) { + throw new Error( + 'Minted Reviewer Router App slug does not exactly match the reviewed repository variable.', + ); + } + const expectedAppOwner = `${appSlug}[bot]`; + const repositorySource = `${owner}/${repo}`.toLowerCase(); + const appliedRules = await github.paginate( + 'GET /repos/{owner}/{repo}/rules/branches/{branch}', + {owner, repo, branch: 'main', per_page: 100}, + ); + const applicableRulesetIDs = [ + ...new Set( + appliedRules + .map(rule => Number(rule.ruleset_id)) + .filter(rulesetID => + Number.isSafeInteger(rulesetID) && rulesetID > 0, + ), + ), + ]; + const activeMainRulesets = []; + for (const rulesetID of applicableRulesetIDs) { + const {data: ruleset} = await github.request( + 'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}', + {owner, repo, ruleset_id: rulesetID}, + ); + if ( + ruleset.enforcement !== 'active' || + ruleset.target !== 'branch' + ) { + throw new Error( + `Applicable ruleset ${ruleset.name || rulesetID} is not an active branch ruleset.`, + ); + } + activeMainRulesets.push(ruleset); + } + const writerRulesets = activeMainRulesets.filter( + ruleset => ruleset.name === 'main-merge-writers', + ); + if (writerRulesets.length !== 1) { + throw new Error( + 'Expected exactly one active main-merge-writers ruleset on main.', + ); + } + const writerRuleset = writerRulesets[0]; + const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; + const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; + if ( + writerRuleset.source_type !== 'Repository' || + writerRuleset.source?.toLowerCase() !== repositorySource || + writerIncludes.length !== 1 || + writerIncludes[0] !== 'refs/heads/main' || + writerExcludes.length !== 0 || + writerRuleset.rules?.length !== 1 || + writerRuleset.rules[0].type !== 'update' || + writerRuleset.rules[0].parameters?.update_allows_fetch_and_merge !== false || + writerRuleset.current_user_can_bypass !== 'pull_requests_only' + ) { + throw new Error( + 'Reviewer Router App requires the exact repository-owned main writer rule with pull-request-only bypass.', + ); + } + for (const ruleset of activeMainRulesets) { + if (ruleset.id === writerRuleset.id) { + continue; + } + if (ruleset.current_user_can_bypass !== 'never') { + throw new Error( + `Reviewer Router App must never bypass main ruleset ${ruleset.name || ruleset.id}.`, + ); + } + } + const skipWorkflowPattern = + /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i; + const openPulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'open', + base: 'main', + per_page: 100, + }); + let migrated = 0; + let skipped = 0; + const failures = []; + + async function disableAutoMerge(pullNumber, pullRequestId, phase) { + await github.graphql( + `mutation DisableAutoMerge($pullRequestId: ID!) { + disablePullRequestAutoMerge( + input: {pullRequestId: $pullRequestId} + ) { + pullRequest { id } + } + }`, + {pullRequestId}, + ); + const {data: revertedPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: pullNumber, + }); + if (revertedPull.auto_merge) { + throw new Error( + `auto-merge owned by ${revertedPull.auto_merge.enabled_by?.login || 'an unknown actor'} survived ${phase}`, + ); + } + return revertedPull; + } + + for (const candidate of openPulls) { + const safeCommitHeadline = `Merge pull request #${candidate.number}`; + const safeCommitBody = + `Merged by the dedicated Reviewer Router GitHub App for PR #${candidate.number}.`; + const candidateOwner = + candidate.auto_merge?.enabled_by?.login?.toLowerCase(); + const candidateMergeTexts = [ + candidate.title, + candidate.auto_merge?.commit_title, + candidate.auto_merge?.commit_message, + ].filter(value => typeof value === 'string'); + const candidateSkipRequested = candidateMergeTexts.some(value => + skipWorkflowPattern.test(value), + ); + const candidateHasSafeAppRequest = + candidateOwner === expectedAppOwner && + candidate.auto_merge?.commit_title === safeCommitHeadline && + candidate.auto_merge?.commit_message === safeCommitBody && + !candidateSkipRequested; + if ( + candidate.draft || + candidate.base.ref !== 'main' || + !candidate.auto_merge || + candidateHasSafeAppRequest + ) { + skipped += 1; + continue; + } + let stage = 'preflight'; + try { + const expectedHeadSha = candidate.head.sha; + const expectedBaseSha = candidate.base.sha; + const {data: currentPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: candidate.number, + }); + const mergeTexts = [ + currentPull.title, + currentPull.auto_merge?.commit_title, + currentPull.auto_merge?.commit_message, + ].filter(value => typeof value === 'string'); + const skipRequested = mergeTexts.some(value => + skipWorkflowPattern.test(value), + ); + const currentOwner = + currentPull.auto_merge?.enabled_by?.login?.toLowerCase(); + const currentHasSafeAppRequest = + currentOwner === expectedAppOwner && + currentPull.auto_merge?.commit_title === safeCommitHeadline && + currentPull.auto_merge?.commit_message === safeCommitBody && + !skipRequested; + if ( + currentPull.head.sha !== expectedHeadSha || + currentPull.base.sha !== expectedBaseSha || + currentPull.state !== 'open' || + currentPull.draft || + currentPull.base.ref !== 'main' || + !currentPull.auto_merge || + currentHasSafeAppRequest + ) { + skipped += 1; + core.info(`PR #${candidate.number} changed before reconciliation; skipped.`); + continue; + } + + stage = skipRequested + ? 'disable workflow-skipping auto-merge request' + : currentOwner === expectedAppOwner + ? 'repair dedicated-App merge metadata' + : 'replace non-dedicated auto-merge owner'; + const disabledPull = await disableAutoMerge( + candidate.number, + currentPull.node_id, + stage, + ); + stage = 'post-disable validation'; + if ( + disabledPull.head.sha !== expectedHeadSha || + disabledPull.base.sha !== expectedBaseSha || + disabledPull.state !== 'open' || + disabledPull.draft || + disabledPull.base.ref !== 'main' + ) { + throw new Error( + 'revision changed after the unsafe owner was disabled; left manual-merge only', + ); + } + if (disabledPull.auto_merge) { + throw new Error('another auto-merge owner appeared after disable'); + } + if (skipRequested) { + throw new Error( + 'workflow-skip directive found in merge metadata; left manual-merge only', + ); + } + + stage = 'dedicated-App enable'; + await github.graphql( + `mutation EnableAutoMerge( + $pullRequestId: ID! + $expectedHeadOid: GitObjectID! + $commitHeadline: String! + $commitBody: String! + ) { + enablePullRequestAutoMerge( + input: { + pullRequestId: $pullRequestId + mergeMethod: MERGE + expectedHeadOid: $expectedHeadOid + commitHeadline: $commitHeadline + commitBody: $commitBody + } + ) { + pullRequest { id } + } + }`, + { + pullRequestId: disabledPull.node_id, + expectedHeadOid: expectedHeadSha, + commitHeadline: safeCommitHeadline, + commitBody: safeCommitBody, + }, + ); + stage = 'post-enable validation'; + const {data: enabledPull} = await github.rest.pulls.get({ + owner, + repo, + pull_number: candidate.number, + }); + const enabledBy = enabledPull.auto_merge?.enabled_by?.login?.toLowerCase(); + const revisionChanged = + enabledPull.head.sha !== expectedHeadSha || + enabledPull.base.sha !== expectedBaseSha || + enabledPull.state !== 'open' || + enabledPull.draft || + enabledPull.base.ref !== 'main'; + if (revisionChanged && enabledBy === expectedAppOwner) { + await disableAutoMerge( + candidate.number, + enabledPull.node_id, + 'stale-enable cleanup', + ); + } + if (revisionChanged) { + throw new Error('revision changed during dedicated-App enable'); + } + if (enabledBy !== expectedAppOwner) { + if (enabledPull.auto_merge) { + await disableAutoMerge( + candidate.number, + enabledPull.node_id, + 'unexpected owner cleanup', + ); + } + throw new Error( + `auto-merge owner ${enabledBy || 'unknown'} does not match ${expectedAppOwner}`, + ); + } + if ( + enabledPull.auto_merge?.commit_title !== safeCommitHeadline || + enabledPull.auto_merge?.commit_message !== safeCommitBody + ) { + await disableAutoMerge( + candidate.number, + enabledPull.node_id, + 'unsafe merge-message cleanup', + ); + throw new Error('dedicated-App merge metadata was not preserved exactly'); + } + migrated += 1; + core.info(`Migrated PR #${candidate.number} auto-merge to ${enabledBy}.`); + } catch (error) { + const failure = `PR #${candidate.number} failed during ${stage}: ${error.message}`; + failures.push(failure); + core.error(failure); + } + } + core.info( + `Reconciliation summary: migrated=${migrated}, skipped=${skipped}, failed=${failures.length}.`, + ); + if (failures.length > 0) { + core.setFailed( + `Legacy auto-merge reconciliation had ${failures.length} failure(s): ${failures.join('; ')}`, + ); + } diff --git a/docs/automation.md b/docs/automation.md index b2d5de21..666d2a07 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -62,9 +62,114 @@ make lint git diff --check ``` +## Reviewer Router GitHub App + +Reviewer requests and native auto-merge intentionally use different +identities. The base-owned `pull_request_target` workflow may use its built-in +`GITHUB_TOKEN` to request reviewers, but it must mint a dedicated GitHub App +installation token before enabling auto-merge. GitHub suppresses most workflow +events created by the built-in token; using it for auto-merge prevents the +merge commit's `push` workflows from running and leaves the exact-SHA Coverage +baseline without a trusted main-scoped producer. + +Configure the dedicated App before merging a workflow revision that requires +it: + +- install it only on `DingTalk-Real-AI/dingtalk-workspace-cli`; +- grant only `Contents: read and write` and `Pull requests: read and write`; +- set repository variable `REVIEWER_ROUTER_APP_CLIENT_ID` to its client ID; +- set `REVIEWER_ROUTER_APP_SLUG` to its exact lowercase slug; +- set repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY` to its private key; +- create one active repository branch ruleset named `main-merge-writers`, + targeting only `refs/heads/main`, with exactly one `Restrict updates` rule + (`update_allows_fetch_and_merge: false`); +- give that ruleset exactly two bypass actors: the Reviewer Router App as an + `Integration` in `pull_request` mode, and `PeterGuy326` (ID `47820304`) in + `always` mode for Formula publication and break-glass recovery; +- never give the App bypass on `main-protection`, `main-quality`, or any other + ruleset, and never reuse `HOMEBREW_PR_TOKEN`, + `RELEASE_GOVERNANCE_TOKEN`, or a personal token for Reviewer Router. + +The workflow limits each minted token to the current repository, requests the +two permissions explicitly, and lets the token action revoke it at job end. +It also requires the minted App slug to equal the reviewed repository variable; +there is no `GITHUB_TOKEN` fallback. Before reading App credentials, the +base-owned workflow revalidates the event's exact base/head and uses its +built-in token only to disable an existing request owned by +`github-actions[bot]` or one whose title or merge metadata requests that GitHub +skip workflows. A mint or permission failure therefore leaves that PR +manual-merge only. The built-in token's `Contents: write` permission is +isolated to this trusted cleanup job and is never used to enable auto-merge; +review routing keeps `Contents: read`. Existing requests owned by a human or +another non-built-in identity are replaced with the exact dedicated-App +request after token minting. Only an already App-owned request with the fixed +headline/body is preserved. The required `Test` context reads the live +repository settings and applied rulesets, verifies the exact writer-rule +shape, and requires its own built-in Actions identity to report +`current_user_can_bypass: never`. Before enabling or reconciling auto-merge, +the minted App independently requires `pull_requests_only` on that writer rule +and `never` on every other active main ruleset. These identity-relative checks +remain available to low-privilege tokens; GitHub deliberately hides the full +`bypass_actors` list from callers without ruleset-write access. Operators must +therefore inspect that list during rollout and keep it at the exact two actors +above. The required `Test` context then briefly waits for the concurrent +router takeover and accepts only a null request or the configured App owner +with exact fixed metadata. A null request is safe for this failure mode because +the built-in Actions identity cannot pass the writer rule; other permitted +identities emit either a protected-main push or the trusted closed-PR repair. +Draft PRs skip this identity check; the explicit `ready_for_review` trigger +reruns admission when they become merge-eligible, +while `edited` and `auto_merge_enabled` rerun both workflows when the PR title +or merge request changes. A human `auto_merge_disabled` event reruns CI without +silently re-enabling the request, leaving it available only to the designated +break-glass identity. The required `Test` context rejects GitHub workflow-skip +directives in the PR title or an existing auto-merge request and verifies the +repository's reviewed `MERGE_MESSAGE` title plus `PR_TITLE` or `BLANK` body +defaults. The dedicated App binds the mutation to the exact head OID and +supplies a fixed safe headline and body, so GitHub cannot copy an unsafe PR +title into its merge commit. +After enabling, the workflow requires the owner to equal the token action's +exact `[bot]` output. If the event base/head changes during the +mutation window, it removes only that App-owned request and fails the run. +The break-glass publisher must preserve a safe final commit message; +`[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`, +`[actions skip]`, and a `skip-checks: true` trailer are forbidden outside the +release-controlled Formula-only path below. + +GitHub may suppress `pull_request_target` entirely for security-sensitive head +branch names, including names that look like commit SHAs. Such a PR receives +neither App takeover nor the closed-event repair. Rename the head branch for +the normal path; if break-glass merge is unavoidable, preserve a safe final +message so the protected-main push CI remains the authoritative producer. + +After installing the App, the protected-main push that deploys this workflow +runs reconciliation automatically. The job enumerates open, ready `main` PRs +with any non-App owner, unsafe App metadata, or workflow-skip metadata. It +revalidates each base/head, converges a safe request to the exact dedicated-App +owner and fixed message, and leaves a workflow-skipping request disabled for +manual correction. It never enables auto-merge where the request was already +null. A mid-migration failure leaves the affected PR disabled for a fresh +routing event or break-glass merge. One PR failure is recorded +without preventing later legacy owners from being attempted; the batch ends +red with a per-PR summary. Manually dispatch `Reviewer routing` from `main` +until the failed count is zero. + +A PR that introduces or rotates this identity still runs the old base-owned +router. Install/configure the App and activate the exact writer ruleset first; +this blocks its legacy `github-actions[bot]` request from writing `main`. After +the governance PR's final push, disable that old request, confirm the live +settings/ruleset contract and all required checks are green for the exact head, +then have only `PeterGuy326` merge that head with the repository-generated safe +merge message. Verify the resulting merge SHA has a `CI` run with `event=push`, +a successful `Coverage` context, and an exact-SHA baseline cache under +`refs/heads/main`. Confirm automatic reconciliation reports zero failures and +zero non-App owners. Finally use a normal canary PR to verify that the dedicated +App is both `enabledBy` and `mergedBy`, and that the same post-merge chain +repeats before declaring the rollout complete. + ## Homebrew Formula Delivery -Official releases use the Release workflow's built-in `GITHUB_TOKEN` to update +Official releases use the designated `HOMEBREW_PR_TOKEN` identity to update exactly one tracked Formula after the immutable GitHub assets and their checksums have passed verification. The publisher validates the rendered Ruby, commits only the configured Formula path, never force-pushes `main`, and retries @@ -72,11 +177,57 @@ from a fresh clone up to three times when `main` advances concurrently. Normal stable and beta releases do not create a Formula PR or run a permission canary. The workflow uses the existing repository-scoped `HOMEBREW_PR_TOKEN` release identity because GitHub does not allow its built-in -Actions App to bypass this repository's rulesets. That identity is the sole -user bypass actor on the two default-branch rulesets. The workflow creates the +Actions App to bypass this repository's rulesets. Its owner is the designated +always-bypass actor for controlled Formula publication and break-glass recovery, +including on `main-merge-writers`. The workflow creates the nine Code Admission checks for the Formula-only commit only after proving its sole parent already has all nine successful checks and the committed Formula -exactly matches this release's verified bytes. +exactly matches this release's verified bytes. Formula commits retain +`[skip ci]`, so the sealing step exposes only the reviewed commit identity to +an independent confirmation job. That job creates the +`Coverage Baseline Cache` acknowledgement and emits the reviewed +`coverage-baseline-promote` repository dispatch. The default-branch +`Coverage Baseline Promotion` workflow independently verifies the exact +single-parent Formula commit, both parent and target admission contexts, and +default-branch containment before checking out the target. It restores only +the exact parent profile, recomputes the complete profile if that cache is +absent, and saves the Formula SHA under the `main` cache scope. Because the +cache save action treats upload errors as warnings, a second lookup must report +`cache-hit=true` for the exact target key before the producer succeeds. The +promotion completes the unique acknowledgement, and the confirmation job +waits for that exact check-run ID. npm and mirror publication depend only on +the immutable release job, so a transient +cache-service failure cannot strand an otherwise valid release between +channels; the final release-delivery gate still fails until the exact cache is +confirmed. Once Formula sealing exposes the target SHA, the confirmation job +also runs when a later immutable-package recheck fails, so a post-push failure +cannot orphan the producer. Rerun the failed promotion/confirmation path after +repairing the producer. Never add a prefix `restore-keys` fallback to this path. + +`Coverage Baseline Repair` is the independent safety net for every merged PR. +Its base-owned `pull_request_target: closed` job never checks out or executes PR +content: it binds the closed event to the current merged PR, verifies the exact +head/base/merge SHA and `main` containment, then emits only a +`coverage-baseline-repair` repository dispatch. Workflow-skip directives alone +do not suppress `pull_request_target`, subject to GitHub's separate +security-sensitive branch-name restriction described above. The low-trust +trigger is forbidden from writing the default-branch cache directly. Before +dispatching, it gives Actions event delivery one minute to expose a run from +the exact protected `.github/workflows/ci.yml` workflow and exits if that normal producer already +owns the SHA, avoiding a duplicate full-suite run. A successful CI producer +must hard-verify its exact cache key. If that run instead completes with any +non-success conclusion, a separate base-owned `workflow_run` dispatcher binds +the exact workflow ID/path, run ID/attempt, conclusion, repository, branch, and +head SHA before requesting repair. `workflow_run` also has read-only +default-branch cache access, so both dispatchers use the reviewed +`repository_dispatch` exception. The dispatched default-branch producer +revalidates the corresponding merged-PR or failed-CI identity before checkout, +restores only the exact target key, recomputes the complete profile on a miss, +and verifies `cache-hit=true` after saving. An hourly schedule refreshes the +event-time `main` SHA after direct break-glass pushes or cache eviction; +`workflow_dispatch` provides the same current-main repair on demand. The +dedicated App identity remains mandatory because events created by the built-in +`GITHUB_TOKEN` can suppress both the main push and the closed-PR event. Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and `Pull requests: write` (the latter remains necessary for withdrawal rollback), diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 8016833d..c8387c6c 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -147,13 +147,57 @@ maintainer pool. A current-head approval or change request is preserved; after a new push, stale activity does not suppress a fresh request, and an outstanding change requester is preferred for continuity. -The branch ruleset keeps one human approval and all nine strict required -contexts, and requires someone other than the latest pusher to approve after -the most recent head update. Repository auto-merge is enabled for ready PRs, -so a PR merges after that approval and the current revision's nine checks are -green. If `main` advances, strict checks rerun before merge. The reviewer -router is orchestration, not a quality context, and must not be added to the -ruleset. +The branch rulesets keep one human approval and all nine strict required +contexts, require someone other than the latest pusher to approve after the +most recent head update, and restrict `main` updates to the dedicated Reviewer +Router App in pull-request mode plus the designated Formula publisher in +always/break-glass mode. Repository auto-merge is enabled for ready PRs, so the +App-owned request merges after that approval and the current revision's nine +checks are green. If `main` advances, strict checks rerun before merge. The +reviewer routing job uses the built-in `GITHUB_TOKEN` to request reviewers with +`Contents: read` and `Pull requests: write`. A separate base-owned cleanup job +isolates the merge-authority permissions (`Contents: write` and `Pull +requests: write`), revalidates the exact event base/head, and uses the built-in +token only to disable an existing request owned by `github-actions[bot]` or one +whose title or merge metadata requests that GitHub skip workflows; it never +enables auto-merge. The job then mints a current-repository installation token +for the dedicated Reviewer Router GitHub App, proves its emitted slug matches +the reviewed `REVIEWER_ROUTER_APP_SLUG`, replaces every non-App request, and +enables native auto-merge with fixed metadata. This +identity boundary is required because GitHub suppresses +most workflow events created by the built-in token; using it for auto-merge would +silently skip the merge commit's protected-main CI and baseline-cache +producer. Token minting or takeover fails closed without falling back to +`GITHUB_TOKEN`: the unsafe request is cleared before credentials are read, and +the required `Test` context live-verifies the exact `main-merge-writers` update +rule and requires its own built-in token to report +`current_user_can_bypass: never`. The minted App separately requires +`pull_requests_only` on that writer rule and `never` on every other active main +ruleset before it can enable or reconcile auto-merge. GitHub hides the complete +`bypass_actors` list from low-privilege callers, so the rollout audit must still +keep the writer list at exactly the Reviewer App plus `PeterGuy326` (ID +`47820304`). The required check finally accepts a null or exact App-owned +request after a short takeover grace period. Null is safe from the suppressed +event path because the built-in Actions identity cannot update `main`; other +permitted identities produce either a main push or the trusted closed-PR +repair. Drafts skip the identity step, while `ready_for_review`, `edited`, +`auto_merge_enabled`, and `auto_merge_disabled` explicitly start fresh admission +for readiness, title, and merge-request changes. Router does not react to +`auto_merge_disabled`, so a +human can deliberately leave the PR manual-only for break-glass handling. +Reviewer routing remains available. The protected-main push that deploys the +workflow automatically migrates every open, ready non-App request and repairs +unsafe App metadata; it disables workflow-skipping requests for correction. A +manual `workflow_dispatch` from `main` is the idempotent retry path. +Reconciliation never enables an originally null request. The reviewer router +is orchestration, not a quality context, and +must not be added to the ruleset. + +GitHub may omit `pull_request_target` for security-sensitive head branch names, +including names that look like commit SHAs. Those PRs cannot use Router App +takeover or the closed-event repair: rename the branch for the supported path, +or use the designated break-glass identity with a safe final message so main +push CI remains the exact-SHA producer. ## Running focused gates locally @@ -213,7 +257,72 @@ the same dedicated cache profile path because GitHub includes that path in the cache version; the runtime-facing candidate and baseline filenames remain separate. Near-miss reuse is forbidden — the caches carry no prefix restore keys, because a neighbouring commit's profile would compare the candidate -against the wrong baseline. Supporting and (when +against the wrong baseline. CI concurrency is keyed by PR number plus exact +event base/head SHA. Duplicate runs for that exact revision may cancel each +other, but a later revision cannot kill an earlier cold-cache producer. Main +runs use the pushed SHA, so a newer main push cannot cancel a predecessor's +producer. + +Every supported main advancement path has an exact-SHA producer. The required +`Test` context rejects GitHub workflow-skip directives in PR and auto-merge +metadata, reruns when that metadata is enabled, disabled, or edited, and +verifies the live App/writer-ruleset identity contract. Reviewer Router +additionally binds auto-merge to the exact head OID and writes a fixed safe +merge headline/body. The sole break-glass publisher must retain a safe final +message; the release-controlled Formula-only path +is the sole supported use of `[skip ci]`. A full source push +saves the assembled profile after the aggregate gate passes. A trusted +documentation or release-seal push independently verifies that the complete +`before...after` diff contains only the reviewed metadata allowlist, restores +only the exact `before` cache, recomputes the full profile if the chain is +cold, and makes that helper a dependency of the required `Coverage` context. +Release-generated Formula commits intentionally retain `[skip ci]`; after +their nine synthetic contexts are sealed, an independent release-governance +job creates an acknowledgement and emits a `coverage-baseline-promote` +repository dispatch. The default-branch promotion +workflow revalidates the exact single-parent Formula identity, successful +parent and target contexts, and main containment before it promotes the exact +parent cache or performs the same full fallback. Every target-main producer +follows its save with a lookup-only restore and requires +`cache-hit=true` for the exact key; this turns the cache action's otherwise +warning-only upload failure or prefix match into a hard failure. Formula +promotion additionally updates one release-created `Coverage Baseline Cache` +check. A separate confirmation job waits for that exact check-run ID while npm +and mirrors remain dependent only on the immutable publication job; cache +failure therefore makes the final delivery gate red without creating a +partially published release. Once Formula sealing exposes its SHA, a later +publication verification failure cannot suppress that confirmation job. + +A separate base-owned `pull_request_target: closed` safety net covers the final +merged SHA even if a human or integration changes the merge message after PR +checks finish. Skip directives alone do not suppress `pull_request_target`, +subject to GitHub's separate security-sensitive branch-name restriction above. +That job executes no PR code and only dispatches after binding the exact +closed/merged PR head, base, merge SHA, and `main` containment. Because GitHub +makes default-branch caches read-only to `pull_request_target`, the dispatcher first +waits up to one minute for a run from the exact protected +`.github/workflows/ci.yml` workflow and exits when that normal producer exists. +A successful main CI hard-verifies the exact key itself. A completed +non-success run starts a separate base-owned `workflow_run` dispatcher, which +binds the exact CI workflow ID/path, run ID/attempt, conclusion, upstream +repository, `main` branch, and head SHA. That trigger is also cache-read-only, +so either trusted dispatcher uses `repository_dispatch`; its producer +revalidates the merged-PR or failed-CI identity, checks out the contained SHA, +and produces/verifies the exact full cache. +An hourly schedule and a main-only manual dispatch repair the event-time main +SHA after a direct break-glass push or cache eviction. The dispatch exception +is intentional: unlike an ordinary event created by `GITHUB_TOKEN`, GitHub +allows `repository_dispatch` to start another workflow. A legacy built-in-token +merge can suppress the closed event too, which is why the required `Test` +identity gate and dedicated Reviewer Router App are still mandatory. + +A cold miss can still occur during a producer race or after cache eviction, +but it remains fail-safe: the PR recomputes the authoritative baseline with a +30-minute job budget and saves a PR-scoped copy for same-PR reruns. It is no +longer possible for a supported main-advance path to omit its producer +silently. That PR-scoped fallback save remains a best-effort acceleration and +does not replace the normal push, metadata, Formula, and merged-PR repair +producers. Supporting and (when platform-selected) native profiles are generated before the aggregate `Coverage` context evaluates them. The aggregate and native gates require 100% coverage for changed executable Go @@ -265,4 +374,11 @@ unproducible required context. The branch ruleset also requires one approval after the latest push. Enable repository auto-merge and automatic head-branch deletion; keep the base-owned -reviewer router outside the required-context list. +reviewer router outside the required-context list. Install its dedicated +GitHub App only on this repository with `Contents: read and write` and `Pull +requests: read and write`; do not grant Actions, Workflows, or Administration. +Give it pull-request-only bypass on `main-merge-writers` and no bypass on any +other ruleset. Store the App client ID and lowercase slug in repository +variables `REVIEWER_ROUTER_APP_CLIENT_ID` and `REVIEWER_ROUTER_APP_SLUG`, and +its private key in repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY`. Do not +reuse release, Homebrew, or personal tokens for this boundary. diff --git a/test/scripts/coverage_workflow_contract_test.go b/test/scripts/coverage_workflow_contract_test.go index 5ffb1298..047569ea 100644 --- a/test/scripts/coverage_workflow_contract_test.go +++ b/test/scripts/coverage_workflow_contract_test.go @@ -135,15 +135,32 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { t.Fatalf("ReadFile(ci.yml) error = %v", err) } admission := string(data) + for _, want := range []string{ + "group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}-{1}-{2}', github.event.pull_request.number, github.event.pull_request.base.sha, github.event.pull_request.head.sha) || format('push-{0}', github.sha) }}", + "cancel-in-progress: true", + } { + if !strings.Contains(admission, want) { + t.Errorf("CI workflow missing exact-revision producer contract %q", want) + } + } + for _, forbidden := range []string{ + "github.event.pull_request.number || github.ref", + "github.event.pull_request.number || github.sha", + } { + if strings.Contains(admission, forbidden) { + t.Errorf("CI producers must not share the coarse concurrency identity %q", forbidden) + } + } currentStart := strings.Index(admission, "\n coverage-current:\n") fullStart := strings.Index(admission, "\n coverage-current-full:\n") supportingStart := strings.Index(admission, "\n coverage-supporting:\n") baselineStart := strings.Index(admission, "\n coverage-baseline:\n") + metadataStart := strings.Index(admission, "\n coverage-main-metadata:\n") gateStart := strings.Index(admission, "\n coverage:\n") policyStart := strings.Index(admission, "\n policy:\n") if currentStart < 0 || fullStart <= currentStart || supportingStart <= fullStart || - baselineStart <= supportingStart || gateStart <= baselineStart || policyStart <= gateStart { + baselineStart <= supportingStart || metadataStart <= baselineStart || gateStart <= metadataStart || policyStart <= gateStart { t.Fatal("CI workflow missing ordered coverage job boundaries") } @@ -175,7 +192,10 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { } } - baselineJob := admission[baselineStart:gateStart] + baselineJob := admission[baselineStart:metadataStart] + if !strings.Contains(baselineJob, "timeout-minutes: 30") { + t.Error("coverage-baseline must retain enough headroom for an authoritative cold-cache fallback") + } cachePath := "coverage-cache.txt" baselineKey := "dws-coverage-full-v2-${{ env.COVERAGE_BASE_REF }}-go${{ steps.setup-go.outputs.go-version }}" for _, want := range []string{ @@ -201,8 +221,50 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { t.Error("coverage baseline cache must stay exact-key; prefix restore-keys can resurrect a wrong-commit baseline") } + metadataJob := admission[metadataStart:gateStart] + for _, want := range []string{ + "github.event_name == 'push'", + "needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true'", + "timeout-minutes: 30", + "fetch-depth: 0", + "PUSH_BEFORE_SHA: ${{ github.event.before }}", + "PUSH_AFTER_SHA: ${{ github.event.after }}", + "git merge-base --is-ancestor \"$PUSH_BEFORE_SHA\" \"$PUSH_AFTER_SHA\"", + "git diff --name-only --no-renames -z", + `^\.changes/[a-z0-9][a-z0-9._-]*\.md$`, + `^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$`, + "Refusing coverage-cache promotion for unreviewed change-fragment path", + "Refusing coverage-cache promotion for executable path", + "COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA", + "id: metadata-current-cache", + "id: metadata-source-cache", + "key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }}", + "go test -count=1 -p 1", + "./ ./cmd/... ./internal/... ./skills/...", + "key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}", + "id: metadata-target-cache-verification", + "lookup-only: true", + "fail-on-cache-miss: true", + "EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }}", + `run: test "$EXACT_CACHE_HIT" = true`, + } { + if !strings.Contains(metadataJob, want) { + t.Errorf("metadata-only main cache producer missing contract %q", want) + } + } + if strings.Contains(metadataJob, "restore-keys") { + t.Error("metadata-only main cache promotion must use exact source and target keys") + } + if strings.Count(metadataJob, "path: "+cachePath) != 4 { + t.Error("metadata-only producer must restore target/source, save, and verify through the shared cache version path") + } + gateJob := admission[gateStart:policyStart] for _, want := range []string{ + "- coverage-main-metadata", + "MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.result }}", + "main_metadata_expected=success", + `"main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_expected"`, "pattern: coverage-current-*", "merge-multiple: true", "for shard in app cli generators helpers remaining; do", @@ -212,6 +274,12 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { "cp coverage.txt coverage-cache.txt", "path: " + cachePath, "key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}", + "name: Verify push coverage cache exists", + "id: push-cache-verification", + "lookup-only: true", + "fail-on-cache-miss: true", + "EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }}", + `run: test "$EXACT_CACHE_HIT" = true`, `"current shards:$CURRENT_FULL_RESULT:$current_full_expected"`, } { if !strings.Contains(gateJob, want) { @@ -219,7 +287,437 @@ func TestCoverageWorkflowShardsAndBaselineCache(t *testing.T) { } } - if strings.Count(gateJob, "path: "+cachePath) != 1 { - t.Error("green main push must save the candidate profile through the same cache path/version as baseline restore") + if strings.Count(gateJob, "path: "+cachePath) != 2 { + t.Error("green main push must save and verify the candidate profile through the same cache path/version as baseline restore") + } +} + +func TestFormulaCoverageBaselinePromotionContract(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + promotionData, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "coverage-baseline-promotion.yml")) + if err != nil { + t.Fatalf("ReadFile(coverage-baseline-promotion.yml) error = %v", err) + } + promotion := string(promotionData) + for _, want := range []string{ + "repository_dispatch:", + "types: [coverage-baseline-promote]", + "checks: write", + "contents: read", + "group: coverage-baseline-promotion-${{ github.event.client_payload.target_sha }}", + "cancel-in-progress: false", + "queue: max", + "github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'", + "timeout-minutes: 30", + "context.payload.client_payload?.target_sha", + "context.payload.client_payload?.source_run_id", + "context.payload.client_payload?.check_run_id", + "targetCommit.parents.length !== 1", + "targetCommit.author?.login !== 'github-actions[bot]'", + "targetCommit.committer?.login !== 'github-actions[bot]'", + "files.length !== 1", + "Formula/dingtalk-workspace-cli.rb", + "Formula/dingtalk-workspace-cli-beta.rb", + "[skip ci", + "await requireSuccessfulAdmission(parentSha, 'Formula parent')", + "await requireSuccessfulAdmission(targetSha, 'Formula target')", + "for (let attempt = 1; attempt <= 6; attempt += 1)", + "setTimeout(resolve, 5000)", + "run.app?.slug !== 'github-actions'", + "run.conclusion !== 'success'", + "basehead: `${targetSha}...${branch.commit.sha}`", + "['ahead', 'identical'].includes(containment.status)", + "promotionCheck.name !== 'Coverage Baseline Cache'", + "promotionCheck.external_id !== promotionExternalId", + "promotionCheck.status !== 'queued'", + "core.setOutput('check_run_id', String(checkRunId))", + "name: Mark Formula cache promotion in progress", + "status: 'in_progress'", + "persist-credentials: false", + "ref: ${{ steps.validate-target.outputs.target_sha }}", + "test \"$(git rev-parse HEAD^)\" = \"$PARENT_SHA\"", + "id: target-cache", + "id: parent-cache", + "key: dws-coverage-full-v2-${{ steps.validate-target.outputs.parent_sha }}-go${{ steps.setup-go.outputs.go-version }}", + "go test -count=1 -p 1", + "./ ./cmd/... ./internal/... ./skills/...", + "key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}", + "lookup-only: true", + "fail-on-cache-miss: true", + "id: formula-target-cache-verification", + "EXACT_CACHE_HIT: ${{ steps.formula-target-cache-verification.outputs.cache-hit }}", + `run: test "$EXACT_CACHE_HIT" = true`, + "name: Complete Formula cache promotion acknowledgement", + "PROMOTION_JOB_STATUS: ${{ job.status }}", + "conclusion: succeeded ? 'success' : 'failure'", + } { + if !strings.Contains(promotion, want) { + t.Errorf("Formula baseline promotion missing contract %q", want) + } + } + for _, want := range []string{ + "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", + "actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff", + "actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830", + "actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830", + } { + if !strings.Contains(promotion, want) { + t.Errorf("Formula baseline promotion must pin trusted action %q", want) + } + } + for _, forbidden := range []string{ + "pull_request:", + "pull_request_target:", + "push:", + "workflow_dispatch:", + "restore-keys", + "HOMEBREW_PR_TOKEN", + "RELEASE_GOVERNANCE_TOKEN", + "contents: write", + } { + if strings.Contains(promotion, forbidden) { + t.Errorf("Formula baseline promotion must not contain %q", forbidden) + } + } + validateIndex := strings.Index(promotion, "name: Validate Formula-only main target") + checkoutIndex := strings.Index(promotion, "name: Check out validated Formula-only target") + if validateIndex < 0 || checkoutIndex <= validateIndex { + t.Error("Formula target must be fully validated before it is checked out or executed") + } + ackReadIndex := strings.Index(promotion, "const {data: promotionCheck} = await github.rest.checks.get") + ackOutputIndex := strings.Index(promotion, "core.setOutput('check_run_id', String(checkRunId))") + targetReadIndex := strings.Index(promotion, "const {data: targetCommit} = await github.rest.repos.getCommit") + if ackReadIndex < 0 || ackOutputIndex <= ackReadIndex || targetReadIndex <= ackOutputIndex { + t.Error("Formula acknowledgement must be identity-checked and bound to the finalizer before target validation") + } + if strings.Count(promotion, "path: coverage-cache.txt") != 4 { + t.Error("Formula promotion must restore target/source, save, and verify through the shared cache version path") + } + + releaseData, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "release.yml")) + if err != nil { + t.Fatalf("ReadFile(release.yml) error = %v", err) + } + release := string(releaseData) + publishStart := strings.Index(release, " publish-release:\n") + sealStart := strings.Index(release, " - name: Seal Formula-only Code Admission contexts\n") + if publishStart < 0 || sealStart <= publishStart { + t.Fatal("release workflow missing Formula-only seal start") + } + if !strings.Contains(release[publishStart:sealStart], "timeout-minutes: 30") { + t.Error("publish-release must not be extended by cache acknowledgement polling") + } + sealEnd := strings.Index(release[sealStart:], "\n - name: Reverify exact immutable npm package\n") + if sealEnd < 0 { + t.Fatal("release workflow missing Formula-only seal boundaries") + } + seal := release[sealStart : sealStart+sealEnd] + for _, want := range []string{ + `core.setOutput("coverage_baseline_required", "true")`, + `core.setOutput("coverage_baseline_commit", commit)`, + } { + if !strings.Contains(seal, want) { + t.Errorf("Formula seal missing cache-promotion dispatch contract %q", want) + } + } + for _, forbidden := range []string{ + "for (let attempt = 1; attempt <= 180; attempt += 1)", + "github.rest.checks.get", + "promotionComplete", + "Coverage Baseline Cache", + "github.rest.repos.createDispatchEvent", + } { + if strings.Contains(seal, forbidden) { + t.Errorf("irreversible publish-release must not wait on cache through %q", forbidden) + } + } + confirmationStart := strings.Index(release, " coverage-baseline-confirmation:\n") + deliveryGateStart := strings.Index(release, " release-delivery-gate:\n") + if confirmationStart < 0 || deliveryGateStart <= confirmationStart { + t.Fatal("release workflow missing independent Formula baseline confirmation job") + } + confirmation := release[confirmationStart:deliveryGateStart] + for _, want := range []string{ + "if (!['true', 'false'].includes(rawRequired))", + "github.rest.checks.create", + "name: 'Coverage Baseline Cache'", + "status: 'queued'", + "external_id: expectedExternalId", + "github.rest.repos.createDispatchEvent", + "event_type: 'coverage-baseline-promote'", + "check_run_id: String(promotionCheck.id)", + "conclusion: 'failure'", + "for (let attempt = 1; attempt <= 180; attempt += 1)", + "github.rest.checks.get", + "currentCheck.name !== 'Coverage Baseline Cache'", + "currentCheck.conclusion !== 'success'", + "setTimeout(resolve, 10000)", + "Formula baseline promotion timed out", + } { + if !strings.Contains(confirmation, want) { + t.Errorf("independent Formula baseline confirmation missing %q", want) + } + } +} + +func TestCoverageBaselineRepairContract(t *testing.T) { + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "coverage-baseline-repair.yml")) + if err != nil { + t.Fatalf("ReadFile(coverage-baseline-repair.yml) error = %v", err) + } + workflow := string(data) + + for _, want := range []string{ + "pull_request_target:", + "branches: [main]", + "types: [closed]", + "workflow_run:", + "workflows: [CI]", + "types: [completed]", + "repository_dispatch:", + "types: [coverage-baseline-repair]", + "schedule:", + `cron: "23 * * * *"`, + "workflow_dispatch:", + "group: coverage-baseline-repair-${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'repository_dispatch' && github.event.client_payload.merge_commit_sha || github.sha }}", + "cancel-in-progress: false", + "queue: max", + } { + if !strings.Contains(workflow, want) { + t.Errorf("coverage baseline repair missing workflow contract %q", want) + } + } + + dispatchStart := strings.Index(workflow, "\n dispatch-merged-pr:\n") + failedDispatchStart := strings.Index(workflow, "\n dispatch-failed-ci:\n") + repairStart := strings.Index(workflow, "\n repair:\n") + if dispatchStart < 0 || failedDispatchStart <= dispatchStart || repairStart <= failedDispatchStart { + t.Fatal("coverage baseline repair is missing ordered merged-PR, failed-CI, and producer jobs") + } + dispatcher := workflow[dispatchStart:failedDispatchStart] + for _, want := range []string{ + "github.event_name == 'pull_request_target'", + "github.event.pull_request.merged == true", + "github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'", + "actions: read", + "contents: write", + "pull-requests: read", + "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b", + "context.payload.repository?.default_branch !== 'main'", + "currentPull.state !== 'closed'", + "currentPull.merged !== true", + "currentPull.base.ref !== 'main'", + "currentPull.base.sha !== baseSha", + "currentPull.head.sha !== headSha", + "currentPull.merge_commit_sha !== mergeCommitSha", + "for (let attempt = 1; attempt <= 6; attempt += 1)", + "setTimeout(resolve, 5000)", + "basehead: `${targetSha}...${branch.commit.sha}`", + "['ahead', 'identical'].includes(comparison.status)", + "for (let attempt = 1; attempt <= 12; attempt += 1)", + "github.rest.actions.getWorkflow", + "workflow_id: '.github/workflows/ci.yml'", + "ciWorkflow.name !== 'CI'", + "ciWorkflow.path !== '.github/workflows/ci.yml'", + "ciWorkflow.state !== 'active'", + "github.rest.actions.listWorkflowRunsForRepo", + "branch: 'main'", + "event: 'push'", + "run.name === 'CI'", + "run.workflow_id === ciWorkflow.id", + "run.path === ciWorkflow.path", + "run.event === 'push'", + "run.head_sha === mergeCommitSha", + "run.head_branch === 'main'", + "['queued', 'in_progress', 'completed'].includes(run.status)", + "setTimeout(resolve, 5000)", + "repair dispatch is unnecessary", + "github.rest.repos.createDispatchEvent", + "event_type: 'coverage-baseline-repair'", + "source: 'merged_pr'", + "pull_number: String(pullNumber)", + "head_sha: headSha", + "base_sha: baseSha", + "merge_commit_sha: mergeCommitSha", + } { + if !strings.Contains(dispatcher, want) { + t.Errorf("merged-PR repair dispatcher missing %q", want) + } + } + ciWorkflowLookup := strings.Index(dispatcher, "github.rest.actions.getWorkflow") + pushRunLookup := strings.Index(dispatcher, "github.rest.actions.listWorkflowRunsForRepo") + repairDispatch := strings.Index(dispatcher, "github.rest.repos.createDispatchEvent") + if ciWorkflowLookup < 0 || pushRunLookup <= ciWorkflowLookup || repairDispatch <= pushRunLookup { + t.Error("merged-PR dispatcher must bind the fixed CI workflow before exhausting exact push-run lookup and dispatch") + } + for _, forbidden := range []string{ + "actions/checkout@", + "actions/cache/", + "actions/setup-go@", + "go test ", + "github.event.pull_request.head.ref", + "secrets.", + } { + if strings.Contains(dispatcher, forbidden) { + t.Errorf("privileged pull_request_target dispatcher must not contain %q", forbidden) + } + } + + failedDispatcher := workflow[failedDispatchStart:repairStart] + for _, want := range []string{ + "github.event_name == 'workflow_run'", + "github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'", + "github.event.workflow_run.name == 'CI'", + "github.event.workflow_run.event == 'push'", + "github.event.workflow_run.head_branch == 'main'", + "github.event.workflow_run.status == 'completed'", + "github.event.workflow_run.conclusion != 'success'", + "actions: read", + "contents: write", + "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b", + "context.payload.repository?.default_branch !== 'main'", + "eventRun?.name !== 'CI'", + "eventRun?.event !== 'push'", + "eventRun?.head_branch !== 'main'", + "eventRun?.status !== 'completed'", + "conclusion === 'success'", + "github.rest.actions.getWorkflow", + "workflow_id: '.github/workflows/ci.yml'", + "github.rest.actions.getWorkflowRun", + "eventRun.workflow_id !== ciWorkflow.id", + "currentRun.workflow_id !== ciWorkflow.id", + "currentRun.name !== 'CI'", + "currentRun.event !== 'push'", + "currentRun.head_branch !== 'main'", + "currentRun.head_sha !== headSha", + "currentRun.run_attempt !== runAttempt", + "currentRun.status !== 'completed'", + "currentRun.conclusion !== conclusion", + "currentRun.repository?.full_name !== upstream", + "currentRun.head_repository?.full_name !== upstream", + "github.rest.repos.createDispatchEvent", + "event_type: 'coverage-baseline-repair'", + "source: 'failed_ci'", + "workflow_run_id: String(runID)", + "workflow_run_attempt: String(runAttempt)", + "workflow_conclusion: conclusion", + "merge_commit_sha: headSha", + } { + if !strings.Contains(failedDispatcher, want) { + t.Errorf("failed-CI repair dispatcher missing %q", want) + } + } + failedRunRead := strings.Index(failedDispatcher, "github.rest.actions.getWorkflowRun") + failedRepairDispatch := strings.Index(failedDispatcher, "github.rest.repos.createDispatchEvent") + if failedRunRead < 0 || failedRepairDispatch <= failedRunRead { + t.Error("workflow_run dispatcher must API-bind the exact failed CI run before repository dispatch") + } + for _, forbidden := range []string{ + "actions/checkout@", + "actions/cache/", + "actions/setup-go@", + "go test ", + "secrets.", + } { + if strings.Contains(failedDispatcher, forbidden) { + t.Errorf("privileged workflow_run dispatcher must not contain %q", forbidden) + } + } + + producer := workflow[repairStart:] + for _, want := range []string{ + "github.event_name == 'repository_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'", + "timeout-minutes: 35", + "actions: read", + "contents: read", + "pull-requests: read", + "id: resolve-target", + "context.eventName === 'repository_dispatch'", + "payload.source === 'merged_pr'", + "payload.pull_number", + "payload.head_sha", + "payload.base_sha", + "payload.merge_commit_sha", + "currentPull.state !== 'closed'", + "currentPull.merged !== true", + "currentPull.base.ref !== 'main'", + "currentPull.base.sha !== baseSha", + "currentPull.head.sha !== headSha", + "currentPull.merge_commit_sha !== targetSha", + "payload.source === 'failed_ci'", + "payload.workflow_run_id", + "payload.workflow_run_attempt", + "payload.workflow_conclusion", + "github.rest.actions.getWorkflow", + "workflow_id: '.github/workflows/ci.yml'", + "github.rest.actions.getWorkflowRun", + "ciWorkflow.name !== 'CI'", + "ciWorkflow.path !== '.github/workflows/ci.yml'", + "currentRun.id !== workflowRunID", + "currentRun.workflow_id !== ciWorkflow.id", + "currentRun.name !== 'CI'", + "currentRun.event !== 'push'", + "currentRun.head_branch !== 'main'", + "currentRun.head_sha !== targetSha", + "currentRun.run_attempt !== workflowRunAttempt", + "currentRun.status !== 'completed'", + "currentRun.conclusion !== workflowConclusion", + "coverage-baseline-repair payload has an unknown source", + "context.ref !== 'refs/heads/main'", + "targetSha = context.sha", + "for (let attempt = 1; attempt <= 6; attempt += 1)", + "basehead: `${targetSha}...${branch.commit.sha}`", + "core.setOutput('target_sha', targetSha)", + "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", + "fetch-depth: 0", + "persist-credentials: false", + "ref: ${{ steps.resolve-target.outputs.target_sha }}", + `run: test "$(git rev-parse HEAD)" = "$TARGET_SHA"`, + "actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff", + "id: target-cache", + "actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830", + "key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}", + "go test -count=1 -p 1", + "./ ./cmd/... ./internal/... ./skills/...", + "actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830", + "id: target-cache-verification", + "lookup-only: true", + "fail-on-cache-miss: true", + "EXACT_CACHE_HIT: ${{ steps.target-cache-verification.outputs.cache-hit }}", + `run: test "$EXACT_CACHE_HIT" = true`, + } { + if !strings.Contains(producer, want) { + t.Errorf("trusted coverage repair producer missing %q", want) + } + } + for _, forbidden := range []string{ + "restore-keys", + "github.event.pull_request.head.ref", + "HOMEBREW_PR_TOKEN", + "RELEASE_GOVERNANCE_TOKEN", + "REVIEWER_ROUTER_APP_PRIVATE_KEY", + "contents: write", + } { + if strings.Contains(producer, forbidden) { + t.Errorf("trusted coverage repair producer must not contain %q", forbidden) + } + } + if strings.Count(producer, "path: coverage-cache.txt") != 3 { + t.Error("coverage repair must restore, save, and independently verify one exact cache path/version") + } + if strings.Count(producer, "key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}") != 3 { + t.Error("coverage repair restore, save, and verification must share one exact target key") + } + validateIndex := strings.Index(producer, "name: Resolve trusted main repair target") + checkoutIndex := strings.Index(producer, "name: Check out exact protected-main target") + if validateIndex < 0 || checkoutIndex <= validateIndex { + t.Error("repository_dispatch input must be fully bound to its protected-main source before checkout") } } diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index 73798dcd..96d3de5c 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -2082,6 +2082,7 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { "- verify-darwin-signatures", "- publish-release", "- publish-channels", + "- coverage-baseline-confirmation", "- mirror-gitee-release", "- repair-npm", "- repair-channel", @@ -2091,6 +2092,7 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { `RELEASE_VALIDATION_RESULT: ${{ needs.release-validation.result }}`, `RELEASE_PLAN_RESULT: ${{ needs.release-plan.result }}`, `SEAL_RELEASE_RESULT: ${{ needs.seal-release.result }}`, + `COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }}`, "require_publication", `require_result release-contract "$RELEASE_CONTRACT_RESULT" success`, `require_result release-validation "$RELEASE_VALIDATION_RESULT" success`, @@ -2098,6 +2100,8 @@ func TestReleaseWorkflowDeliveryGateFailsClosed(t *testing.T) { `require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success`, `require_result publish-release "$PUBLISH_RELEASE_RESULT" success`, `require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success`, + `require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" success`, + `require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped`, "workflow_dispatch:recover_release", "workflow_dispatch:create_release", "workflow_dispatch:plan_release", @@ -2438,8 +2442,12 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) { } for _, required := range []string{ + "timeout-minutes: 30", + `coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }}`, + `coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }}`, "id: homebrew-stable", "id: homebrew-beta", + "id: seal-formula", `FORMULA_CHANGED: ${{ steps.homebrew-stable.outputs.formula_changed || steps.homebrew-beta.outputs.formula_changed }}`, `FORMULA_COMMIT: ${{ steps.homebrew-stable.outputs.published_commit || steps.homebrew-beta.outputs.published_commit }}`, } { @@ -2481,6 +2489,8 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) { `head_sha: commit`, `status: "completed"`, `conclusion: "success"`, + `core.setOutput("coverage_baseline_required", "true")`, + `core.setOutput("coverage_baseline_commit", commit)`, } { if !strings.Contains(seal, required) { t.Errorf("Formula-only Code Admission sealing is missing %q", required) @@ -2492,12 +2502,17 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) { } } if strings.Count(seal, "github.rest.checks.create") != 1 { - t.Error("Formula-only checks must be created only by the single verified context loop") + t.Error("Formula sealing must write only the reviewed Code Admission contexts") } for _, forbidden := range []string{ "head_sha: context.sha", "head_sha: parent", "head_sha: branch.data.commit.sha", + "github.rest.checks.get", + "promotionComplete", + "setTimeout(resolve, 10000)", + "Coverage Baseline Cache", + "github.rest.repos.createDispatchEvent", } { if strings.Contains(seal, forbidden) { t.Errorf("Formula-only Code Admission must not mark an unverified head green: found %q", forbidden) @@ -2505,6 +2520,9 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) { } createCheck := strings.Index(seal, "github.rest.checks.create") + if createCheck == -1 { + t.Error("Formula sealing must create the verified Code Admission contexts") + } for name, marker := range map[string]string{ "single-parent Formula-only identity": "const exactFormulaCommit", "successful parent contexts": "invalidParentContexts.length > 0", @@ -2518,6 +2536,67 @@ func TestReleaseWorkflowSealsOnlyVerifiedFormulaCommitContexts(t *testing.T) { } } +func TestReleaseWorkflowConfirmsFormulaCacheWithoutBlockingChannels(t *testing.T) { + t.Parallel() + workflow := readReleaseWorkflow(t) + publishJob := releaseWorkflowSection(t, workflow, " publish-release:\n", "\n publish-channels:\n") + channelsJob := releaseWorkflowSection(t, workflow, " publish-channels:\n", "\n mirror-gitee-release:\n") + confirmation := releaseWorkflowSection( + t, + workflow, + " coverage-baseline-confirmation:\n", + "\n release-delivery-gate:\n", + ) + + for _, required := range []string{ + `if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }}`, + "needs: publish-release", + "timeout-minutes: 35", + "checks: write", + "contents: write", + `BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }}`, + `FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }}`, + "if (!['true', 'false'].includes(rawRequired))", + "Formula baseline requirement is invalid", + "github.rest.checks.create", + "name: 'Coverage Baseline Cache'", + "status: 'queued'", + "external_id: expectedExternalId", + "github.rest.repos.createDispatchEvent", + "event_type: 'coverage-baseline-promote'", + "source_run_id: String(context.runId)", + "check_run_id: String(promotionCheck.id)", + "github.rest.checks.update", + "conclusion: 'failure'", + "github.rest.checks.get", + "currentCheck.head_sha !== targetSha", + "currentCheck.external_id !== expectedExternalId", + "currentCheck.app?.slug !== 'github-actions'", + "for (let attempt = 1; attempt <= 180; attempt += 1)", + "currentCheck.conclusion !== 'success'", + "await new Promise(resolve => setTimeout(resolve, 10000))", + "Formula baseline promotion timed out", + } { + if !strings.Contains(confirmation, required) { + t.Errorf("Formula baseline confirmation missing %q", required) + } + } + if strings.Contains(channelsJob, "coverage-baseline-confirmation") || + strings.Contains(channelsJob, "needs.coverage-baseline-confirmation") { + t.Error("cache acknowledgement must not block npm or mirror publication") + } + for _, forbidden := range []string{ + "Coverage Baseline Cache", + "github.rest.repos.createDispatchEvent", + "github.rest.checks.get", + "promotionComplete", + } { + if strings.Contains(publishJob, forbidden) { + t.Errorf("irreversible publication job must not own cache lifecycle marker %q", forbidden) + } + } +} + func TestReleaseWorkflowWaitsForNPMDistTagPropagation(t *testing.T) { workflow := readReleaseWorkflow(t) script := releaseWorkflowRunScript( diff --git a/test/scripts/reviewer_router_workflow_test.go b/test/scripts/reviewer_router_workflow_test.go index 61fe102a..4c9e5419 100644 --- a/test/scripts/reviewer_router_workflow_test.go +++ b/test/scripts/reviewer_router_workflow_test.go @@ -16,6 +16,7 @@ import ( type reviewerRouterWorkflow struct { On map[string]reviewerRouterTrigger `yaml:"on"` Permissions map[string]string `yaml:"permissions"` + Concurrency map[string]string `yaml:"concurrency"` Jobs map[string]reviewerRouterJob `yaml:"jobs"` } @@ -25,12 +26,16 @@ type reviewerRouterTrigger struct { } type reviewerRouterJob struct { - If string `yaml:"if"` - Steps []reviewerRouterStep `yaml:"steps"` + If string `yaml:"if"` + Permissions map[string]string `yaml:"permissions"` + Steps []reviewerRouterStep `yaml:"steps"` } type reviewerRouterStep struct { + ID string `yaml:"id"` + Name string `yaml:"name"` Uses string `yaml:"uses"` + Env map[string]string `yaml:"env"` With map[string]string `yaml:"with"` } @@ -68,8 +73,8 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { } } - if len(workflow.On) != 1 { - t.Fatalf("workflow triggers = %v, want pull_request_target only", workflow.On) + if len(workflow.On) != 3 { + t.Fatalf("workflow triggers = %v, want pull_request_target plus protected-main reconciliation", workflow.On) } trigger, ok := workflow.On["pull_request_target"] if !ok { @@ -78,47 +83,108 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { if wantBranches := []string{"main"}; !reflect.DeepEqual(trigger.Branches, wantBranches) { t.Fatalf("pull_request_target branches = %v, want %v", trigger.Branches, wantBranches) } - wantTypes := []string{"opened", "synchronize", "reopened", "ready_for_review"} + wantTypes := []string{"opened", "synchronize", "reopened", "ready_for_review", "edited", "auto_merge_enabled"} if !reflect.DeepEqual(trigger.Types, wantTypes) { t.Fatalf("pull_request_target types = %v, want %v", trigger.Types, wantTypes) } + if _, ok := workflow.On["workflow_dispatch"]; !ok { + t.Fatalf("workflow triggers = %v, want workflow_dispatch reconciliation", workflow.On) + } + push, ok := workflow.On["push"] + if !ok || !reflect.DeepEqual(push.Branches, []string{"main"}) { + t.Fatalf("push trigger = %v, want protected main only", push) + } wantPermissions := map[string]string{ - "contents": "write", + "contents": "read", "pull-requests": "write", } if !reflect.DeepEqual(workflow.Permissions, wantPermissions) { t.Fatalf("workflow permissions = %v, want exactly %v", workflow.Permissions, wantPermissions) } + wantConcurrency := map[string]string{ + "group": "reviewer-router-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || 'reconcile-main' }}", + "cancel-in-progress": "${{ github.event_name == 'pull_request_target' }}", + } + if !reflect.DeepEqual(workflow.Concurrency, wantConcurrency) { + t.Fatalf("workflow concurrency = %v, want serialized reconciliation and cancellable PR events %v", workflow.Concurrency, wantConcurrency) + } - if len(workflow.Jobs) != 1 { - t.Fatalf("workflow jobs = %v, want one isolated routing job", workflow.Jobs) + if len(workflow.Jobs) != 3 { + t.Fatalf("workflow jobs = %v, want event routing plus controlled reconciliation", workflow.Jobs) } job, ok := workflow.Jobs["route"] if !ok { t.Fatalf("workflow jobs = %v, want route", workflow.Jobs) } - if job.If != "github.event.pull_request.draft == false" { + if job.If != "github.event_name == 'pull_request_target' && github.event.pull_request.draft == false" { t.Fatalf("route.if = %q, want non-draft guard", job.If) } const checkoutSHA = "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683" const githubScriptSHA = "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b" - if len(job.Steps) != 2 || job.Steps[0].Uses != checkoutSHA || job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" || job.Steps[0].With["persist-credentials"] != "false" || job.Steps[1].Uses != githubScriptSHA { - t.Fatalf("route steps = %#v, want trusted base checkout followed by pinned github-script", job.Steps) + const appTokenSHA = "actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1" + if len(job.Steps) != 2 || + job.Steps[0].Uses != checkoutSHA || + job.Steps[0].With["ref"] != "${{ github.event.pull_request.base.sha }}" || + job.Steps[0].With["persist-credentials"] != "false" || + job.Steps[1].Uses != githubScriptSHA { + t.Fatalf("route steps = %#v, want trusted base checkout followed by routing only", job.Steps) } - script := job.Steps[1].With["script"] + autoMergeJob, ok := workflow.Jobs["manage-auto-merge"] + if !ok { + t.Fatalf("workflow jobs = %v, want manage-auto-merge", workflow.Jobs) + } + if autoMergeJob.If != "github.event_name == 'pull_request_target' && github.event.pull_request.draft == false" { + t.Fatalf("manage-auto-merge.if = %q, want exact ready event guard", autoMergeJob.If) + } + wantAutoPermissions := map[string]string{ + "contents": "write", + "pull-requests": "write", + } + if !reflect.DeepEqual(autoMergeJob.Permissions, wantAutoPermissions) { + t.Fatalf("manage-auto-merge permissions = %v, want exactly %v", autoMergeJob.Permissions, wantAutoPermissions) + } + if len(autoMergeJob.Steps) != 3 || + autoMergeJob.Steps[0].Uses != githubScriptSHA || + autoMergeJob.Steps[1].ID != "reviewer-router-token" || + autoMergeJob.Steps[1].Uses != appTokenSHA || + autoMergeJob.Steps[2].Uses != githubScriptSHA { + t.Fatalf("manage-auto-merge steps = %#v, want unsafe-owner cleanup before dedicated App enable", autoMergeJob.Steps) + } + + appToken := autoMergeJob.Steps[1] + if len(appToken.With) != 6 { + t.Fatalf("reviewer-router token inputs = %v, want exactly the reviewed repository scope and two permissions", appToken.With) + } + for key, want := range map[string]string{ + "client-id": "${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }}", + "private-key": "${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }}", + "owner": "${{ github.repository_owner }}", + "repositories": "${{ github.event.repository.name }}", + "permission-contents": "write", + "permission-pull-requests": "write", + } { + if got := appToken.With[key]; got != want { + t.Errorf("reviewer-router token input %q = %q, want %q", key, got, want) + } + } + if _, ok := appToken.With["skip-token-revoke"]; ok { + t.Error("reviewer-router App token must be revoked automatically after the job") + } + + routingScript := job.Steps[1].With["script"] for _, want := range []string{ "REVIEWER_POOL", "resolveReviewRouting", "github.rest.pulls.get", "github.rest.pulls.listFiles", "currentPull.head.sha !== eventHeadSha", + "currentPull.base.sha !== eventBaseSha", "currentPull.state !== 'open'", "currentPull.draft", "currentPull.base.ref !== 'main'", "getReadyEventPull('review request')", - "getReadyEventPull('auto-merge enable')", "context.payload.action === 'synchronize'", "context.payload.sender?.login?.toLowerCase()", "reviewer.toLowerCase() !== author", @@ -142,22 +208,391 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { "github.rest.pulls.requestReviewers", "trying the next candidate", "Reviewer routing hit an unexpected error", - "enablePullRequestAutoMerge", - "mergeMethod: MERGE", "core.warning", } { - if !strings.Contains(script, want) { + if !strings.Contains(routingScript, want) { t.Errorf("reviewer router script is missing contract marker %q", want) } } + for _, forbidden := range []string{ + "enablePullRequestAutoMerge", + "disablePullRequestAutoMerge", + "mergeMethod: MERGE", + } { + if strings.Contains(routingScript, forbidden) { + t.Errorf("built-in routing token must not control auto-merge through %q", forbidden) + } + } + + cleanupStep := autoMergeJob.Steps[0] + if _, ok := cleanupStep.With["github-token"]; ok { + t.Error("unsafe-owner cleanup must use only the job-scoped built-in token") + } + cleanupScript := cleanupStep.With["script"] + for _, want := range []string{ + "currentPull.head.sha !== eventHeadSha", + "currentPull.base.sha !== eventBaseSha", + "currentPull.state !== 'open'", + "currentPull.draft", + "currentPull.base.ref !== 'main'", + "const unsafeOwner = 'github-actions[bot]'", + "const skipWorkflowPattern =", + "currentPull.title", + "currentPull.auto_merge?.commit_title", + "currentPull.auto_merge?.commit_message", + "const skipRequested =", + "enabledBy !== unsafeOwner", + "disablePullRequestAutoMerge", + "cleanedPull.auto_merge", + "Cleared workflow-skipping auto-merge metadata", + "throw new Error", + } { + if !strings.Contains(cleanupScript, want) { + t.Errorf("unsafe-owner cleanup is missing contract marker %q", want) + } + } + if strings.Contains(cleanupScript, "enablePullRequestAutoMerge") { + t.Error("built-in cleanup token must never enable auto-merge") + } + if got := strings.Count(cleanupScript, "const skipWorkflowPattern ="); got != 1 { + t.Errorf("unsafe-request cleanup skip-workflow pattern declarations = %d, want exactly 1", got) + } + + autoMergeStep := autoMergeJob.Steps[2] + if got, want := autoMergeStep.With["github-token"], "${{ steps.reviewer-router-token.outputs.token }}"; got != want { + t.Fatalf("auto-merge github-token = %q, want %q", got, want) + } + if got, want := autoMergeStep.Env["MINTED_REVIEWER_ROUTER_APP_SLUG"], "${{ steps.reviewer-router-token.outputs.app-slug }}"; got != want { + t.Fatalf("minted auto-merge App slug = %q, want %q", got, want) + } + if got, want := autoMergeStep.Env["REVIEWER_ROUTER_APP_SLUG"], "${{ vars.REVIEWER_ROUTER_APP_SLUG }}"; got != want { + t.Fatalf("configured auto-merge App slug = %q, want %q", got, want) + } + autoMergeScript := autoMergeStep.With["script"] + for _, want := range []string{ + "github.rest.pulls.get", + "currentPull.head.sha !== eventHeadSha", + "currentPull.base.sha !== eventBaseSha", + "currentPull.state !== 'open'", + "currentPull.draft", + "currentPull.base.ref !== 'main'", + "getReadyEventPull('auto-merge enable')", + "process.env.MINTED_REVIEWER_ROUTER_APP_SLUG?.trim()", + "process.env.REVIEWER_ROUTER_APP_SLUG?.trim()", + "appSlug === 'github-actions'", + "mintedAppSlug !== appSlug", + "const expectedAppOwner = `${appSlug}[bot]`", + "GET /repos/{owner}/{repo}/rules/branches/{branch}", + ".map(rule => Number(rule.ruleset_id))", + "Number.isSafeInteger(rulesetID) && rulesetID > 0", + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}", + "ruleset.enforcement !== 'active'", + "ruleset.target !== 'branch'", + "ruleset.name === 'main-merge-writers'", + "writerRuleset.source_type !== 'Repository'", + "writerRuleset.source?.toLowerCase() !== repositorySource", + "writerIncludes[0] !== 'refs/heads/main'", + "writerExcludes.length !== 0", + "writerRuleset.rules?.length !== 1", + "writerRuleset.rules[0].type !== 'update'", + "update_allows_fetch_and_merge !== false", + "current_user_can_bypass !== 'pull_requests_only'", + "ruleset.current_user_can_bypass !== 'never'", + "Reviewer Router App must never bypass main ruleset", + "const skipWorkflowPattern =", + "const safeCommitHeadline = `Merge pull request #${pullNumber}`", + "const safeCommitBody =", + "workflow-skip cleanup", + "already has the reviewed ${expectedAppOwner} auto-merge request", + "preserving it", + "non-dedicated owner cleanup", + "unsafe dedicated-App metadata cleanup", + "getReadyEventPull('post-owner cleanup')", + "enablePullRequestAutoMerge", + "mergeMethod: MERGE", + "expectedHeadOid: $expectedHeadOid", + "commitHeadline: $commitHeadline", + "commitBody: $commitBody", + "expectedHeadOid: eventHeadSha", + "const exactRevision =", + "enabledBy === expectedAppOwner", + "disablePullRequestAutoMerge", + "unsafe-merge-message cleanup", + "enabledPull.auto_merge?.commit_title !== safeCommitHeadline", + "enabledPull.auto_merge?.commit_message !== safeCommitBody", + "if (revertedPull.auto_merge)", + "enabledBy !== expectedAppOwner", + } { + if !strings.Contains(autoMergeScript, want) { + t.Errorf("dedicated auto-merge script is missing contract marker %q", want) + } + } + if got := strings.Count(autoMergeScript, "const skipWorkflowPattern ="); got != 1 { + t.Errorf("dedicated auto-merge script skip-workflow pattern declarations = %d, want exactly 1", got) + } + for _, want := range []string{ + "if (!exactRevision && enabledBy === expectedAppOwner)", + "disableAutoMerge(enabledPull, 'stale-enable cleanup')", + "existingOwner === expectedAppOwner &&", + "disableAutoMerge(currentPull, 'workflow-skip cleanup')", + "currentPull.auto_merge.commit_title === safeCommitHeadline", + "currentPull.auto_merge.commit_message === safeCommitBody", + "disableAutoMerge(enabledPull, 'unexpected owner cleanup')", + } { + if !strings.Contains(autoMergeScript, want) { + t.Errorf("dedicated App stale-state cleanup is missing %q", want) + } + } + for _, forbidden := range []string{"core.warning", "catch ("} { + if strings.Contains(autoMergeScript, forbidden) { + t.Errorf("dedicated auto-merge must fail closed instead of handling errors through %q", forbidden) + } + } + for _, forbidden := range []string{ + "rule.ruleset_source_type", + "rule.ruleset_source?.toLowerCase()", + "ruleset.source_type !== 'Repository'", + "ruleset.source?.toLowerCase() !== repositorySource", + } { + if strings.Contains(autoMergeScript, forbidden) { + t.Errorf("dedicated App must inspect every applicable main ruleset, not prefilter through %q", forbidden) + } + } + + reconcile, ok := workflow.Jobs["reconcile"] + if !ok { + t.Fatalf("workflow jobs = %v, want reconcile", workflow.Jobs) + } + if reconcile.If != "(github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/main' && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'" { + t.Fatalf("reconcile.if = %q, want exact protected-main manual guard", reconcile.If) + } + if len(reconcile.Steps) != 2 || + reconcile.Steps[0].ID != "reviewer-router-reconcile-token" || + reconcile.Steps[0].Uses != appTokenSHA || + reconcile.Steps[1].Uses != githubScriptSHA { + t.Fatalf("reconcile steps = %#v, want scoped App token followed by pinned migration script", reconcile.Steps) + } + if len(reconcile.Permissions) != 0 { + t.Fatalf("reconcile built-in token permissions = %v, want none", reconcile.Permissions) + } + if len(reconcile.Steps[0].With) != 6 { + t.Fatalf("reconcile token inputs = %v, want exactly the reviewed repository scope and two permissions", reconcile.Steps[0].With) + } + for key, want := range map[string]string{ + "client-id": "${{ vars.REVIEWER_ROUTER_APP_CLIENT_ID }}", + "private-key": "${{ secrets.REVIEWER_ROUTER_APP_PRIVATE_KEY }}", + "owner": "${{ github.repository_owner }}", + "repositories": "${{ github.event.repository.name }}", + "permission-contents": "write", + "permission-pull-requests": "write", + } { + if got := reconcile.Steps[0].With[key]; got != want { + t.Errorf("reconcile token input %q = %q, want %q", key, got, want) + } + } + if _, ok := reconcile.Steps[0].With["skip-token-revoke"]; ok { + t.Error("reconcile App token must be revoked automatically after the job") + } + if got, want := reconcile.Steps[1].With["github-token"], "${{ steps.reviewer-router-reconcile-token.outputs.token }}"; got != want { + t.Fatalf("reconcile github-token = %q, want %q", got, want) + } + if got, want := reconcile.Steps[1].Env["MINTED_REVIEWER_ROUTER_APP_SLUG"], "${{ steps.reviewer-router-reconcile-token.outputs.app-slug }}"; got != want { + t.Fatalf("minted reconcile App slug = %q, want %q", got, want) + } + if got, want := reconcile.Steps[1].Env["REVIEWER_ROUTER_APP_SLUG"], "${{ vars.REVIEWER_ROUTER_APP_SLUG }}"; got != want { + t.Fatalf("configured reconcile App slug = %q, want %q", got, want) + } + reconcileScript := reconcile.Steps[1].With["script"] + for _, want := range []string{ + "github.paginate(github.rest.pulls.list", + "state: 'open'", + "base: 'main'", + "candidate.draft", + "const candidateOwner =", + "const candidateSkipRequested =", + "const candidateHasSafeAppRequest =", + "candidateOwner === expectedAppOwner", + "!candidate.auto_merge", + "candidateHasSafeAppRequest", + "currentPull.head.sha !== expectedHeadSha", + "currentPull.base.sha !== expectedBaseSha", + "currentPull.state !== 'open'", + "currentPull.draft", + "currentPull.base.ref !== 'main'", + "const currentHasSafeAppRequest =", + "currentOwner === expectedAppOwner", + "currentHasSafeAppRequest", + "async function disableAutoMerge(pullNumber, pullRequestId, phase)", + "disablePullRequestAutoMerge", + "if (revertedPull.auto_merge)", + "return revertedPull", + "replace non-dedicated auto-merge owner", + "repair dedicated-App merge metadata", + "enablePullRequestAutoMerge", + "mergeMethod: MERGE", + "const expectedAppOwner = `${appSlug}[bot]`", + "GET /repos/{owner}/{repo}/rules/branches/{branch}", + ".map(rule => Number(rule.ruleset_id))", + "Number.isSafeInteger(rulesetID) && rulesetID > 0", + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}", + "ruleset.enforcement !== 'active'", + "ruleset.target !== 'branch'", + "ruleset.name === 'main-merge-writers'", + "writerRuleset.source_type !== 'Repository'", + "writerRuleset.source?.toLowerCase() !== repositorySource", + "writerIncludes[0] !== 'refs/heads/main'", + "writerExcludes.length !== 0", + "writerRuleset.rules?.length !== 1", + "writerRuleset.rules[0].type !== 'update'", + "update_allows_fetch_and_merge !== false", + "current_user_can_bypass !== 'pull_requests_only'", + "ruleset.current_user_can_bypass !== 'never'", + "Reviewer Router App must never bypass main ruleset", + "const skipWorkflowPattern =", + "const skipRequested =", + "workflow-skip directive found in merge metadata; left manual-merge only", + "const safeCommitHeadline = `Merge pull request #${candidate.number}`", + "const safeCommitBody =", + "expectedHeadOid: $expectedHeadOid", + "commitHeadline: $commitHeadline", + "commitBody: $commitBody", + "expectedHeadOid: expectedHeadSha", + "enabledPull.auto_merge?.commit_title !== safeCommitHeadline", + "enabledPull.auto_merge?.commit_message !== safeCommitBody", + "const failures = []", + "catch (error)", + "failures.push(failure)", + "core.setFailed", + "revisionChanged && enabledBy === expectedAppOwner", + "enabledBy !== expectedAppOwner", + "unexpected owner cleanup", + "unsafe merge-message cleanup", + } { + if !strings.Contains(reconcileScript, want) { + t.Errorf("reconciliation script is missing contract marker %q", want) + } + } + if disableIndex, enableIndex := strings.Index(reconcileScript, "disablePullRequestAutoMerge"), strings.Index(reconcileScript, "enablePullRequestAutoMerge"); disableIndex < 0 || enableIndex <= disableIndex { + t.Error("legacy reconciliation must disable the exact unsafe owner before App-token enable") + } + if got := strings.Count(reconcileScript, "const skipWorkflowPattern ="); got != 1 { + t.Errorf("reconciliation script skip-workflow pattern declarations = %d, want exactly 1", got) + } + if strings.Contains(reconcileScript, "const unsafeOwner") { + t.Error("reconciliation must converge every non-App owner, not only the legacy built-in owner") + } + for _, forbidden := range []string{ + "rule.ruleset_source_type", + "rule.ruleset_source?.toLowerCase()", + "ruleset.source_type !== 'Repository'", + "ruleset.source?.toLowerCase() !== repositorySource", + } { + if strings.Contains(reconcileScript, forbidden) { + t.Errorf("reconciliation App must inspect every applicable main ruleset, not prefilter through %q", forbidden) + } + } for _, forbidden := range []string{ "['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED']", "PeterGuy326", - "core.setFailed", + "secrets.HOMEBREW_PR_TOKEN", + "secrets.RELEASE_GOVERNANCE_TOKEN", + "secrets.GITHUB_TOKEN", } { if strings.Contains(string(data), forbidden) { t.Errorf("reviewer router must not contain %q", forbidden) } } } + +func TestCodeAdmissionEnforcesReviewerRouterWriterBoundary(t *testing.T) { + t.Parallel() + + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("Abs(repo root) error = %v", err) + } + data, err := os.ReadFile(filepath.Join(root, ".github", "workflows", "ci.yml")) + if err != nil { + t.Fatalf("ReadFile(ci.yml) error = %v", err) + } + workflow := string(data) + if !strings.Contains(workflow, "pull_request:\n types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]") { + t.Error("CI must rerun admission when a draft becomes ready or merge metadata changes") + } + start := strings.Index(workflow, "\n test:\n") + end := strings.Index(workflow, "\n test-darwin:\n") + if start < 0 || end <= start { + t.Fatal("CI workflow missing Test aggregate boundaries") + } + testJob := workflow[start:end] + for _, want := range []string{ + "contents: read", + "pull-requests: read", + "name: Verify auto-merge identity", + "if: github.event_name == 'pull_request' && github.event.pull_request.draft == false", + "REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }}", + "process.env.REVIEWER_ROUTER_APP_SLUG?.trim()", + "const expectedAppOwner = `${appSlug}[bot]`", + "github.rest.pulls.get", + "github.rest.repos.get", + "repository.merge_commit_title !== 'MERGE_MESSAGE'", + "!['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message)", + "const writerRulesetName = 'main-merge-writers'", + "GET /repos/{owner}/{repo}/rules/branches/{branch}", + "rule.ruleset_source_type === 'Repository'", + "rule.ruleset_source?.toLowerCase() === repositorySource", + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}", + "ruleset.enforcement !== 'active'", + "ruleset.target !== 'branch'", + "ruleset.source_type !== 'Repository'", + "ruleset.source?.toLowerCase() !== repositorySource", + "writerIncludes[0] !== 'refs/heads/main'", + "writerExcludes.length !== 0", + "writerRuleset.rules?.length !== 1", + "writerRuleset.rules[0].type !== 'update'", + "update_allows_fetch_and_merge !== false", + "writerRuleset.current_user_can_bypass !== 'never'", + "deny this built-in Actions identity any bypass", + "const skipWorkflowPattern =", + "currentPull.title", + "currentPull.auto_merge?.commit_title", + "currentPull.auto_merge?.commit_message", + "merge metadata contains a GitHub workflow-skip directive", + "currentPull.head.sha !== eventHeadSha", + "currentPull.base.sha !== eventBaseSha", + "currentPull.state !== 'open'", + "currentPull.draft", + "currentPull.base.ref !== 'main'", + "currentPull.auto_merge", + "enabled_by?.login", + "enabledBy === expectedAppOwner", + "currentPull.auto_merge.commit_title === safeCommitHeadline", + "currentPull.auto_merge.commit_message === safeCommitBody", + "must be null or owned by ${expectedAppOwner}", + "const maxAttempts = 6", + "setTimeout(resolve, 5000)", + "core.setFailed", + } { + if !strings.Contains(testJob, want) { + t.Errorf("Test aggregate missing auto-merge identity contract %q", want) + } + } + if !strings.Contains(testJob, "Null and non-built-in merge identities emit either the protected-main") || + !strings.Contains(testJob, "main-merge-writers never lets it update main") { + t.Error("auto-merge identity comment must name the push/repair paths and built-in writer self-check") + } + for _, forbidden := range []string{ + "REVIEWER_ROUTER_APP_PRIVATE_KEY", + "HOMEBREW_PR_TOKEN", + "RELEASE_GOVERNANCE_TOKEN", + "can emit protected-main workflow events", + "pull-requests: write", + "contents: write", + "actions: write", + } { + if strings.Contains(testJob, forbidden) { + t.Errorf("read-only admission identity check must not consume %q", forbidden) + } + } +} From f3e98d55e8a3a1bef52f449c39b46b5bedec03a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=8D=B7?= Date: Mon, 24 Aug 2026 10:30:32 +0800 Subject: [PATCH 02/33] feat: add edu & college vendor extension commands Add five hidden vendor extension commands for education scenarios: - edu-contact: school/class/family/teacher contact management - edu-group: student/class group lifecycle - edu-app: homework, notices, report cards, diplomas, class circles - edu-familygroup: family group management, child binding, app permissions - college-contact: university dept/employee/alumni/graduate management All route to dedicated MCP servers via callMCPToolOnServer with endpoints registered in openSupplementServers (helper-only, not VisibleProducts). Includes: helper implementations, dispatch tests, mono/multi skill docs, coverage.yaml registration, context-budget compliance, and .changes fragment. --- .changes/edu-college-vendor-extensions.md | 5 + internal/helpers/college_contact.go | 6012 +++++++++++++++++ internal/helpers/college_contact_test.go | 680 ++ internal/helpers/edu_app.go | 3546 ++++++++++ internal/helpers/edu_app_test.go | 400 ++ internal/helpers/edu_contact.go | 1917 ++++++ internal/helpers/edu_contact_test.go | 583 ++ internal/helpers/edu_family_group.go | 605 ++ internal/helpers/edu_family_group_test.go | 286 + internal/helpers/edu_group.go | 846 +++ internal/helpers/edu_group_test.go | 281 + internal/helpers/register_edu.go | 28 + pkg/edition/default.go | 30 + skills/content-qa/mono-multi-coverage.yaml | 20 + skills/mono/SKILL.md | 7 + .../references/products/college-contact.md | 135 + skills/mono/references/products/edu-app.md | 109 + .../mono/references/products/edu-contact.md | 74 + .../references/products/edu-familygroup.md | 68 + skills/mono/references/products/edu-group.md | 55 + skills/multi/dingtalk-misc/SKILL.md | 7 +- .../references/college-contact.md | 135 + .../multi/dingtalk-misc/references/edu-app.md | 115 + .../dingtalk-misc/references/edu-contact.md | 92 + .../references/edu-familygroup.md | 68 + .../dingtalk-misc/references/edu-group.md | 77 + 26 files changed, 16180 insertions(+), 1 deletion(-) create mode 100644 .changes/edu-college-vendor-extensions.md create mode 100644 internal/helpers/college_contact.go create mode 100644 internal/helpers/college_contact_test.go create mode 100644 internal/helpers/edu_app.go create mode 100644 internal/helpers/edu_app_test.go create mode 100644 internal/helpers/edu_contact.go create mode 100644 internal/helpers/edu_contact_test.go create mode 100644 internal/helpers/edu_family_group.go create mode 100644 internal/helpers/edu_family_group_test.go create mode 100644 internal/helpers/edu_group.go create mode 100644 internal/helpers/edu_group_test.go create mode 100644 internal/helpers/register_edu.go create mode 100644 skills/mono/references/products/college-contact.md create mode 100644 skills/mono/references/products/edu-app.md create mode 100644 skills/mono/references/products/edu-contact.md create mode 100644 skills/mono/references/products/edu-familygroup.md create mode 100644 skills/mono/references/products/edu-group.md create mode 100644 skills/multi/dingtalk-misc/references/college-contact.md create mode 100644 skills/multi/dingtalk-misc/references/edu-app.md create mode 100644 skills/multi/dingtalk-misc/references/edu-contact.md create mode 100644 skills/multi/dingtalk-misc/references/edu-familygroup.md create mode 100644 skills/multi/dingtalk-misc/references/edu-group.md diff --git a/.changes/edu-college-vendor-extensions.md b/.changes/edu-college-vendor-extensions.md new file mode 100644 index 00000000..463f8c96 --- /dev/null +++ b/.changes/edu-college-vendor-extensions.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`. diff --git a/internal/helpers/college_contact.go b/internal/helpers/college_contact.go new file mode 100644 index 00000000..65fb4ca2 --- /dev/null +++ b/internal/helpers/college_contact.go @@ -0,0 +1,6012 @@ +package helpers + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +// ────────────────────────────────────────────────────────── +// dws college-contact — 高校通讯录扩展 +// 共 65 个工具,按 dept / employee / alumni / graduate / group 分组 +// ────────────────────────────────────────────────────────── + +func newCollegeContactCommand() *cobra.Command { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: "college-contact", + Selection: contract.ProductSelectionDecl{ + AgentSummary: "高校通讯录:高校组织架构/院系部门管理/师生员工管理/校友/毕业年级/规则管理", + UseWhen: []string{ + "用户要查询或管理钉钉高校通讯录,包括组织架构、部门、教职工、学生、校友、毕业年级等信息。", + }, + AvoidWhen: []string{ + "家校通讯录用 edu-contact;家校群用 edu-group;家庭群用 edu-familygroup;家校应用用 edu-app。", + }, + }, + }) + root := &cobra.Command{ + Use: "college-contact", + Short: "高校通讯录", + Long: `钉钉高校通讯录扩展:查询高校组织架构/院系/师生信息等。`, + Hidden: true, + RunE: groupRunE, + } + + // ════════════════════════════════════════════════════════════ + // dept 子命令组 — 部门管理 + // ════════════════════════════════════════════════════════════ + + deptCmd := &cobra.Command{Use: "dept", Short: "部门管理", RunE: groupRunE} + + getStandardStructureCmd := &cobra.Command{ + Use: "get-standard-structure", + Short: "查询高校标准架构信息", + Long: `查询高校标准架构信息,返回组织 ID、行政架构部门 ID 及架构固定部门 ID 映射。 +所有参数均可选: + --dept-id 按部门 ID 过滤 + --staff-id 按员工 staffId 过滤 + --keyword 按关键词搜索 + --offset / --size 分页参数`, + Example: ` dws college-contact dept get-standard-structure + dws college-contact dept get-standard-structure --dept-id 12345 + dws college-contact dept get-standard-structure --keyword 计算机 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + if v, _ := cmd.Flags().GetString("dept-id"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input["deptId"] = n + } + if v, _ := cmd.Flags().GetString("staff-id"); strings.TrimSpace(v) != "" { + input["staffId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("keyword"); strings.TrimSpace(v) != "" { + input["keyword"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("offset"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--offset 须为整数: %w", err) + } + input["offset"] = n + } + if v, _ := cmd.Flags().GetString("size"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--size 须为整数: %w", err) + } + input["size"] = n + } + return callMCPToolOnServer("college-contact", "get_college_standard_structure", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(getStandardStructureCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_college_standard_structure", + CanonicalPath: "college-contact.get_college_standard_structure", + CLIPath: "college-contact dept get-standard-structure", + PrimaryCLIPath: "college-contact dept get-standard-structure", + }, + Description: "查询高校标准架构信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_college_standard_structure"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询高校标准架构信息", + UseWhen: []string{"需要查询高校标准架构信息时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact dept get-standard-structure --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "keyword", Property: "input.keyword", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + getDeptDetailCmd := &cobra.Command{ + Use: "get-detail", + Short: "查询部门详情", + Long: `查询高校部门详情,返回组织 ID、部门 ID、部门类型、部门编码及行政架构部门 ID。 +--dept-id 为必填,其余可选: + --staff-id 按员工 staffId 过滤 + --keyword 按关键词搜索 + --offset / --size 分页参数(size 默认 20)`, + Example: ` dws college-contact dept get-detail --dept-id 12345 + dws college-contact dept get-detail --dept-id 12345 --keyword 计算机 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIDRaw, _ := cmd.Flags().GetString("dept-id") + deptIDRaw = strings.TrimSpace(deptIDRaw) + if deptIDRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptID, err := strconv.ParseInt(deptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input := map[string]any{"deptId": deptID} + if v, _ := cmd.Flags().GetString("staff-id"); strings.TrimSpace(v) != "" { + input["staffId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("keyword"); strings.TrimSpace(v) != "" { + input["keyword"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("offset"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--offset 须为整数: %w", err) + } + input["offset"] = n + } + if v, _ := cmd.Flags().GetString("size"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--size 须为整数: %w", err) + } + input["size"] = n + } + return callMCPToolOnServer("college-contact", "get_college_dept_detail", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(getDeptDetailCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_college_dept_detail", + CanonicalPath: "college-contact.get_college_dept_detail", + CLIPath: "college-contact dept get-detail", + PrimaryCLIPath: "college-contact dept get-detail", + }, + Description: "查询部门详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_college_dept_detail"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询部门详情", + UseWhen: []string{"需要查询部门详情时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact dept get-detail --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "keyword", Property: "input.keyword", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + getDeptChainCmd := &cobra.Command{ + Use: "get-chain", + Short: "查询部门链", + Long: `查询部门链信息,返回从根节点到当前部门的部门列表及部门行业化信息。 +--dept-id 为必填,其余可选: + --staff-id 按员工 staffId 过滤 + --keyword 按关键词搜索 + --offset / --size 分页参数(size 默认 20)`, + Example: ` dws college-contact dept get-chain --dept-id 12345 + dws college-contact dept get-chain --dept-id 12345 --keyword 计算机 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIDRaw, _ := cmd.Flags().GetString("dept-id") + deptIDRaw = strings.TrimSpace(deptIDRaw) + if deptIDRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptID, err := strconv.ParseInt(deptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input := map[string]any{"deptId": deptID} + if v, _ := cmd.Flags().GetString("staff-id"); strings.TrimSpace(v) != "" { + input["staffId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("keyword"); strings.TrimSpace(v) != "" { + input["keyword"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("offset"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--offset 须为整数: %w", err) + } + input["offset"] = n + } + if v, _ := cmd.Flags().GetString("size"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--size 须为整数: %w", err) + } + input["size"] = n + } + return callMCPToolOnServer("college-contact", "get_college_dept_chain", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(getDeptChainCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_college_dept_chain", + CanonicalPath: "college-contact.get_college_dept_chain", + CLIPath: "college-contact dept get-chain", + PrimaryCLIPath: "college-contact dept get-chain", + }, + Description: "查询部门链", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_college_dept_chain"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询部门链", + UseWhen: []string{"需要查询部门链时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact dept get-chain --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "keyword", Property: "input.keyword", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + searchContactCmd := &cobra.Command{ + Use: "search", + Short: "搜索通讯录", + Long: `按关键词搜索高校通讯录,返回匹配的人员、部门及角色集合。 +--dept-id 与 --keyword 均为必填,其余可选: + --staff-id 按员工 staffId 过滤 + --offset / --size 分页参数(size 默认 20)`, + Example: ` dws college-contact dept search --dept-id 12345 --keyword 张三 + dws college-contact dept search --dept-id 12345 --keyword 计算机 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIDRaw, _ := cmd.Flags().GetString("dept-id") + deptIDRaw = strings.TrimSpace(deptIDRaw) + if deptIDRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptID, err := strconv.ParseInt(deptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + keyword, _ := cmd.Flags().GetString("keyword") + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return fmt.Errorf("--keyword 为必填参数") + } + input := map[string]any{"deptId": deptID, "keyword": keyword} + if v, _ := cmd.Flags().GetString("staff-id"); strings.TrimSpace(v) != "" { + input["staffId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("offset"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--offset 须为整数: %w", err) + } + input["offset"] = n + } + if v, _ := cmd.Flags().GetString("size"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--size 须为整数: %w", err) + } + input["size"] = n + } + return callMCPToolOnServer("college-contact", "search_college_contact", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(searchContactCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "search_college_contact", + CanonicalPath: "college-contact.search_college_contact", + CLIPath: "college-contact dept search", + PrimaryCLIPath: "college-contact dept search", + }, + Description: "搜索通讯录", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "search_college_contact"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "搜索通讯录", + UseWhen: []string{"需要搜索通讯录时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact dept search --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "keyword", Property: "input.keyword", Required: boolPtr(true)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // flags + 构建命令树 + // ════════════════════════════════════════════════════════════ + + getStandardStructureCmd.Flags().String("dept-id", "", "部门 ID") + getStandardStructureCmd.Flags().String("staff-id", "", "员工 staffId") + getStandardStructureCmd.Flags().String("keyword", "", "搜索关键词") + getStandardStructureCmd.Flags().String("offset", "", "分页偏移量") + getStandardStructureCmd.Flags().String("size", "", "分页大小") + + getDeptDetailCmd.Flags().String("dept-id", "", "部门 ID(必填)") + getDeptDetailCmd.Flags().String("staff-id", "", "员工 staffId") + getDeptDetailCmd.Flags().String("keyword", "", "搜索关键词") + getDeptDetailCmd.Flags().String("offset", "", "分页偏移量") + getDeptDetailCmd.Flags().String("size", "", "分页大小(默认 20)") + + getDeptChainCmd.Flags().String("dept-id", "", "部门 ID(必填)") + getDeptChainCmd.Flags().String("staff-id", "", "员工 staffId") + getDeptChainCmd.Flags().String("keyword", "", "搜索关键词") + getDeptChainCmd.Flags().String("offset", "", "分页偏移量") + getDeptChainCmd.Flags().String("size", "", "分页大小(默认 20)") + + searchContactCmd.Flags().String("dept-id", "", "部门 ID(必填)") + searchContactCmd.Flags().String("keyword", "", "搜索关键词(必填)") + searchContactCmd.Flags().String("staff-id", "", "员工 staffId") + searchContactCmd.Flags().String("offset", "", "分页偏移量") + searchContactCmd.Flags().String("size", "", "分页大小(默认 20)") + + createDeptCmd := &cobra.Command{ + Use: "create", + Short: "创建部门", + Long: `创建高校部门,返回创建成功的部门 ID。 +必填: + --super-id 上级部门 ID + --stru-dept-id 所属组织架构部门 ID + --name 部门名称 + --dept-type 部门类型 + --create-dept-group 是否创建部门群(true/false) +可选: + --dept-id 部门 ID + --dept-code 部门编码 + --brief 简介 + --phone 电话`, + Example: ` dws college-contact dept create --super-id 100 --stru-dept-id 200 --name 计算机学院 --dept-type college --create-dept-group true + dws college-contact dept create --super-id 100 --stru-dept-id 200 --name 计算机学院 --dept-type college --create-dept-group false --brief "学院简介" --phone 010-12345678 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"super-id", "superId"}, + {"stru-dept-id", "struDeptId"}, + } { + raw, _ := cmd.Flags().GetString(pair.flag) + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--%s 为必填参数", pair.flag) + } + n, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + + // 必填 string + for _, flag := range []string{"name", "dept-type"} { + v, _ := cmd.Flags().GetString(flag) + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("--%s 为必填参数", flag) + } + key := flag + if flag == "dept-type" { + key = "deptType" + } + input[key] = v + } + + // 必填 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + if !cmd.Flags().Changed("create-dept-group") { + return fmt.Errorf("--create-dept-group 为必填参数(true/false)") + } + cdgRaw, _ := cmd.Flags().GetString("create-dept-group") + createDeptGroup, err := strconv.ParseBool(strings.TrimSpace(cdgRaw)) + if err != nil { + return fmt.Errorf("--create-dept-group 须为 true 或 false: %w", err) + } + input["createDeptGroup"] = createDeptGroup + + // 可选 int64 + if v, _ := cmd.Flags().GetString("dept-id"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input["deptId"] = n + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"dept-code", "deptCode"}, + {"brief", "brief"}, + {"phone", "phone"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + return callMCPToolOnServer("college-contact", "create_college_contact_dept", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(createDeptCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "create_college_contact_dept", + CanonicalPath: "college-contact.create_college_contact_dept", + CLIPath: "college-contact dept create", + PrimaryCLIPath: "college-contact dept create", + }, + Description: "创建部门", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "create_college_contact_dept"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建部门", + UseWhen: []string{"需要创建部门时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact dept create --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "super-id", Property: "input.superId", Required: boolPtr(true)}, + {Name: "stru-dept-id", Property: "input.struDeptId", Required: boolPtr(true)}, + {Name: "name", Property: "input.name", Required: boolPtr(true)}, + {Name: "dept-type", Property: "input.deptType", Required: boolPtr(true)}, + {Name: "create-dept-group", Property: "input.createDeptGroup", Required: boolPtr(true)}, + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, + {Name: "dept-code", Property: "input.deptCode", Required: boolPtr(false)}, + {Name: "brief", Property: "input.brief", Required: boolPtr(false)}, + {Name: "phone", Property: "input.phone", Required: boolPtr(false)}, + }, + }, + }) + + createDeptCmd.Flags().String("super-id", "", "上级部门 ID(必填)") + createDeptCmd.Flags().String("stru-dept-id", "", "所属组织架构部门 ID(必填)") + createDeptCmd.Flags().String("name", "", "部门名称(必填)") + createDeptCmd.Flags().String("dept-type", "", "部门类型(必填)") + createDeptCmd.Flags().String("create-dept-group", "", "是否创建部门群(必填,true/false)") + createDeptCmd.Flags().String("dept-id", "", "部门 ID") + createDeptCmd.Flags().String("dept-code", "", "部门编码") + createDeptCmd.Flags().String("brief", "", "简介") + createDeptCmd.Flags().String("phone", "", "电话") + + updateDeptCmd := &cobra.Command{ + Use: "update", + Short: "更新部门", + Long: `更新高校部门信息,返回操作是否成功。 +必填: + --dept-id 部门 ID + --dept-type 部门类型 +可选: + --name 部门名称 + --stru-dept-id 所属组织架构部门 ID + --super-id 上级部门 ID + --dept-code 部门编码 + --brief 简介 + --phone 电话 + --create-dept-group 是否创建部门群(true/false)`, + Example: ` dws college-contact dept update --dept-id 12345 --dept-type college + dws college-contact dept update --dept-id 12345 --dept-type college --name 计算机学院 --brief 学院简介 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 int64 + deptIDRaw, _ := cmd.Flags().GetString("dept-id") + deptIDRaw = strings.TrimSpace(deptIDRaw) + if deptIDRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptID, err := strconv.ParseInt(deptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input["deptId"] = deptID + + // 必填 string + v, _ := cmd.Flags().GetString("dept-type") + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("--dept-type 为必填参数") + } + input["deptType"] = v + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"stru-dept-id", "struDeptId"}, + {"super-id", "superId"}, + } { + if raw, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(raw) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + if cmd.Flags().Changed("create-dept-group") { + cdgRaw, _ := cmd.Flags().GetString("create-dept-group") + createDeptGroup, err := strconv.ParseBool(strings.TrimSpace(cdgRaw)) + if err != nil { + return fmt.Errorf("--create-dept-group 须为 true 或 false: %w", err) + } + input["createDeptGroup"] = createDeptGroup + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"name", "name"}, + {"dept-code", "deptCode"}, + {"brief", "brief"}, + {"phone", "phone"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + return callMCPToolOnServer("college-contact", "update_college_contact_dept", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(updateDeptCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "update_college_contact_dept", + CanonicalPath: "college-contact.update_college_contact_dept", + CLIPath: "college-contact dept update", + PrimaryCLIPath: "college-contact dept update", + }, + Description: "更新部门", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "update_college_contact_dept"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "更新部门", + UseWhen: []string{"需要更新部门时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact dept update --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "dept-type", Property: "input.deptType", Required: boolPtr(true)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "stru-dept-id", Property: "input.struDeptId", Required: boolPtr(false)}, + {Name: "super-id", Property: "input.superId", Required: boolPtr(false)}, + {Name: "dept-code", Property: "input.deptCode", Required: boolPtr(false)}, + {Name: "brief", Property: "input.brief", Required: boolPtr(false)}, + {Name: "phone", Property: "input.phone", Required: boolPtr(false)}, + {Name: "create-dept-group", Property: "input.createDeptGroup", Required: boolPtr(false)}, + }, + }, + }) + + updateDeptCmd.Flags().String("dept-id", "", "部门 ID(必填)") + updateDeptCmd.Flags().String("dept-type", "", "部门类型(必填)") + updateDeptCmd.Flags().String("name", "", "部门名称") + updateDeptCmd.Flags().String("stru-dept-id", "", "所属组织架构部门 ID") + updateDeptCmd.Flags().String("super-id", "", "上级部门 ID") + updateDeptCmd.Flags().String("dept-code", "", "部门编码") + updateDeptCmd.Flags().String("brief", "", "简介") + updateDeptCmd.Flags().String("phone", "", "电话") + updateDeptCmd.Flags().String("create-dept-group", "", "是否创建部门群(true/false)") + + deleteDeptCmd := &cobra.Command{ + Use: "delete", + Short: "删除部门", + Long: `删除高校部门,返回操作是否成功。 +--dept-id 为必填。 + +注意:删除操作不可逆。非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact dept delete --dept-id 12345 --dry-run + dws college-contact dept delete --dept-id 12345 -y + dws college-contact dept delete --dept-id 12345 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIDRaw, _ := cmd.Flags().GetString("dept-id") + deptIDRaw = strings.TrimSpace(deptIDRaw) + if deptIDRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptID, err := strconv.ParseInt(deptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + if err := collegeContactConfirmDestructive(cmd, "删除部门"); err != nil { + return err + } + return callMCPToolOnServer("college-contact", "delete_college_contact_dept", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + + DeclareLeafMetadata(deleteDeptCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "delete_college_contact_dept", + CanonicalPath: "college-contact.delete_college_contact_dept", + CLIPath: "college-contact dept delete", + PrimaryCLIPath: "college-contact dept delete", + }, + Description: "删除部门", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "delete_college_contact_dept"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "删除部门", + UseWhen: []string{"需要删除部门时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact dept delete --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + deleteDeptCmd.Flags().String("dept-id", "", "部门 ID(必填)") + + batchUpdateDeptTypeCmd := &cobra.Command{ + Use: "batch-update-type", + Short: "批量修改部门类型", + Long: `批量修改部门类型,返回操作是否成功。 +必填: + --dept-ids 部门 ID 列表,逗号分隔(如 1,2,3) + --target-dept-type 目标部门类型`, + Example: ` dws college-contact dept batch-update-type --dept-ids 100,200,300 --target-dept-type college + dws college-contact dept batch-update-type --dept-ids 100,200 --target-dept-type department -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIDsRaw, _ := cmd.Flags().GetString("dept-ids") + deptIDsRaw = strings.TrimSpace(deptIDsRaw) + if deptIDsRaw == "" { + return fmt.Errorf("--dept-ids 为必填参数") + } + var deptIDs []int64 + for _, part := range strings.Split(deptIDsRaw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return fmt.Errorf("--dept-ids 中 %q 须为整数: %w", part, err) + } + deptIDs = append(deptIDs, n) + } + if len(deptIDs) == 0 { + return fmt.Errorf("--dept-ids 不能为空列表") + } + + targetDeptType, _ := cmd.Flags().GetString("target-dept-type") + targetDeptType = strings.TrimSpace(targetDeptType) + if targetDeptType == "" { + return fmt.Errorf("--target-dept-type 为必填参数") + } + + input := map[string]any{ + "deptIds": deptIDs, + "targetDeptType": targetDeptType, + } + + return callMCPToolOnServer("college-contact", "batch_update_dept_type", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(batchUpdateDeptTypeCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "batch_update_dept_type", + CanonicalPath: "college-contact.batch_update_dept_type", + CLIPath: "college-contact dept batch-update-type", + PrimaryCLIPath: "college-contact dept batch-update-type", + }, + Description: "批量修改部门类型", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "batch_update_dept_type"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "批量修改部门类型", + UseWhen: []string{"需要批量修改部门类型时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact dept batch-update-type --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-ids", Property: "input.deptIds", Required: boolPtr(true)}, + {Name: "target-dept-type", Property: "input.targetDeptType", Required: boolPtr(true)}, + }, + }, + }) + + batchUpdateDeptTypeCmd.Flags().String("dept-ids", "", "部门 ID 列表,逗号分隔(必填)") + batchUpdateDeptTypeCmd.Flags().String("target-dept-type", "", "目标部门类型(必填)") + + listEmployeesCmd := &cobra.Command{ + Use: "list-employees", + Short: "查询部门员工列表", + Long: `查询指定部门的员工列表,返回员工总数及员工列表。 +必填: + --dept-id 部门 ID +可选: + --staff-id 员工 staffId + --name 员工姓名 + --mobile 手机号 + --job-number 工号 + --main-dept-id 主部门 ID + --login-id-type 登录类型 + --exclusive-account 是否独占账号(true/false) + --send-active-sms 是否发送激活短信(true/false) + --staff-ids 批量操作的 staffId 列表,逗号分隔 + --target-dept-id 变更目标部门 ID + --offset / --size 分页参数 + --order-field 排序字段(如 job_number / order_in_dept) + --ordering 排序顺序(asc / desc)`, + Example: ` dws college-contact employee list-employees --dept-id 12345 + dws college-contact employee list-employees --dept-id 12345 --name 张三 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 int64 + deptIDRaw, _ := cmd.Flags().GetString("dept-id") + deptIDRaw = strings.TrimSpace(deptIDRaw) + if deptIDRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptID, err := strconv.ParseInt(deptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input["deptId"] = deptID + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"main-dept-id", "mainDeptId"}, + {"target-dept-id", "targetDeptId"}, + {"offset", "offset"}, + {"size", "size"}, + } { + if raw, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(raw) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + for _, pair := range []struct { + flag string + key string + }{ + {"exclusive-account", "exclusiveAccount"}, + {"send-active-sms", "sendActiveSms"}, + } { + if cmd.Flags().Changed(pair.flag) { + raw, _ := cmd.Flags().GetString(pair.flag) + v, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("--%s 须为 true 或 false: %w", pair.flag, err) + } + input[pair.key] = v + } + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"staff-id", "staffId"}, + {"name", "name"}, + {"mobile", "mobile"}, + {"job-number", "jobNumber"}, + {"login-id-type", "loginIdType"}, + {"order-field", "orderField"}, + {"ordering", "ordering"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + // 可选 []string: --staff-ids + if raw, _ := cmd.Flags().GetString("staff-ids"); strings.TrimSpace(raw) != "" { + var staffIDs []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIDs = append(staffIDs, part) + } + } + if len(staffIDs) > 0 { + input["staffIds"] = staffIDs + } + } + + return callMCPToolOnServer("college-contact", "list_employees", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(listEmployeesCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "list_employees", + CanonicalPath: "college-contact.list_employees", + CLIPath: "college-contact employee list-employees", + PrimaryCLIPath: "college-contact employee list-employees", + }, + Description: "查询部门员工列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "list_employees"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询部门员工列表", + UseWhen: []string{"需要查询部门员工列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee list-employees --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(false)}, + {Name: "job-number", Property: "input.jobNumber", Required: boolPtr(false)}, + {Name: "main-dept-id", Property: "input.mainDeptId", Required: boolPtr(false)}, + {Name: "login-id-type", Property: "input.loginIdType", Required: boolPtr(false)}, + {Name: "exclusive-account", Property: "input.exclusiveAccount", Required: boolPtr(false)}, + {Name: "send-active-sms", Property: "input.sendActiveSms", Required: boolPtr(false)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(false)}, + {Name: "target-dept-id", Property: "input.targetDeptId", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + {Name: "order-field", Property: "input.orderField", Required: boolPtr(false)}, + {Name: "ordering", Property: "input.ordering", Required: boolPtr(false)}, + }, + }, + }) + + listEmployeesCmd.Flags().String("dept-id", "", "部门 ID(必填)") + listEmployeesCmd.Flags().String("staff-id", "", "员工 staffId") + listEmployeesCmd.Flags().String("name", "", "员工姓名") + listEmployeesCmd.Flags().String("mobile", "", "手机号") + listEmployeesCmd.Flags().String("job-number", "", "工号") + listEmployeesCmd.Flags().String("main-dept-id", "", "主部门 ID") + listEmployeesCmd.Flags().String("login-id-type", "", "登录类型") + listEmployeesCmd.Flags().String("exclusive-account", "", "是否独占账号(true/false)") + listEmployeesCmd.Flags().String("send-active-sms", "", "是否发送激活短信(true/false)") + listEmployeesCmd.Flags().String("staff-ids", "", "批量操作的 staffId 列表,逗号分隔") + listEmployeesCmd.Flags().String("target-dept-id", "", "变更目标部门 ID") + listEmployeesCmd.Flags().String("offset", "", "分页偏移量") + listEmployeesCmd.Flags().String("size", "", "分页大小") + listEmployeesCmd.Flags().String("order-field", "", "排序字段(如 job_number / order_in_dept)") + listEmployeesCmd.Flags().String("ordering", "", "排序顺序(asc / desc)") + + listUnacceptedEmployeesCmd := &cobra.Command{ + Use: "list-unaccepted", + Short: "查询未接受邀请的员工列表", + Long: `查询指定部门下未接受邀请的员工列表,返回员工总数及员工列表。 +必填: + --dept-id 部门 ID +可选: + --staff-id 员工 staffId + --name 员工姓名 + --mobile 手机号 + --job-number 工号 + --emp-type 员工类型(college_student / college_teacher) + --main-dept-id 主部门 ID + --login-id-type 登录类型 + --exclusive-account 是否独占账号(true/false) + --send-active-sms 是否发送激活短信(true/false) + --staff-ids 批量操作的 staffId 列表,逗号分隔 + --target-dept-id 变更目标部门 ID + --offset / --size 分页参数 + --order-field 排序字段 + --ordering 排序顺序(asc / desc)`, + Example: ` dws college-contact employee list-unaccepted --dept-id 12345 + dws college-contact employee list-unaccepted --dept-id 12345 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 int64 + deptIDRaw, _ := cmd.Flags().GetString("dept-id") + deptIDRaw = strings.TrimSpace(deptIDRaw) + if deptIDRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptID, err := strconv.ParseInt(deptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input["deptId"] = deptID + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"main-dept-id", "mainDeptId"}, + {"target-dept-id", "targetDeptId"}, + {"offset", "offset"}, + {"size", "size"}, + } { + if raw, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(raw) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + for _, pair := range []struct { + flag string + key string + }{ + {"exclusive-account", "exclusiveAccount"}, + {"send-active-sms", "sendActiveSms"}, + } { + if cmd.Flags().Changed(pair.flag) { + raw, _ := cmd.Flags().GetString(pair.flag) + v, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("--%s 须为 true 或 false: %w", pair.flag, err) + } + input[pair.key] = v + } + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"staff-id", "staffId"}, + {"name", "name"}, + {"mobile", "mobile"}, + {"job-number", "jobNumber"}, + {"emp-type", "empType"}, + {"login-id-type", "loginIdType"}, + {"order-field", "orderField"}, + {"ordering", "ordering"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + // 可选 []string: --staff-ids + if raw, _ := cmd.Flags().GetString("staff-ids"); strings.TrimSpace(raw) != "" { + var staffIDs []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIDs = append(staffIDs, part) + } + } + if len(staffIDs) > 0 { + input["staffIds"] = staffIDs + } + } + + return callMCPToolOnServer("college-contact", "list_unaccepted_employees", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(listUnacceptedEmployeesCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "list_unaccepted_employees", + CanonicalPath: "college-contact.list_unaccepted_employees", + CLIPath: "college-contact employee list-unaccepted", + PrimaryCLIPath: "college-contact employee list-unaccepted", + }, + Description: "查询未接受邀请的员工列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "list_unaccepted_employees"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询未接受邀请的员工列表", + UseWhen: []string{"需要查询未接受邀请的员工列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee list-unaccepted --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(false)}, + {Name: "job-number", Property: "input.jobNumber", Required: boolPtr(false)}, + {Name: "emp-type", Property: "input.empType", Required: boolPtr(false)}, + {Name: "main-dept-id", Property: "input.mainDeptId", Required: boolPtr(false)}, + {Name: "login-id-type", Property: "input.loginIdType", Required: boolPtr(false)}, + {Name: "exclusive-account", Property: "input.exclusiveAccount", Required: boolPtr(false)}, + {Name: "send-active-sms", Property: "input.sendActiveSms", Required: boolPtr(false)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(false)}, + {Name: "target-dept-id", Property: "input.targetDeptId", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + {Name: "order-field", Property: "input.orderField", Required: boolPtr(false)}, + {Name: "ordering", Property: "input.ordering", Required: boolPtr(false)}, + }, + }, + }) + + listUnacceptedEmployeesCmd.Flags().String("dept-id", "", "部门 ID(必填)") + listUnacceptedEmployeesCmd.Flags().String("staff-id", "", "员工 staffId") + listUnacceptedEmployeesCmd.Flags().String("name", "", "员工姓名") + listUnacceptedEmployeesCmd.Flags().String("mobile", "", "手机号") + listUnacceptedEmployeesCmd.Flags().String("job-number", "", "工号") + listUnacceptedEmployeesCmd.Flags().String("emp-type", "", "员工类型(college_student / college_teacher)") + listUnacceptedEmployeesCmd.Flags().String("main-dept-id", "", "主部门 ID") + listUnacceptedEmployeesCmd.Flags().String("login-id-type", "", "登录类型") + listUnacceptedEmployeesCmd.Flags().String("exclusive-account", "", "是否独占账号(true/false)") + listUnacceptedEmployeesCmd.Flags().String("send-active-sms", "", "是否发送激活短信(true/false)") + listUnacceptedEmployeesCmd.Flags().String("staff-ids", "", "批量操作的 staffId 列表,逗号分隔") + listUnacceptedEmployeesCmd.Flags().String("target-dept-id", "", "变更目标部门 ID") + listUnacceptedEmployeesCmd.Flags().String("offset", "", "分页偏移量") + listUnacceptedEmployeesCmd.Flags().String("size", "", "分页大小") + listUnacceptedEmployeesCmd.Flags().String("order-field", "", "排序字段") + listUnacceptedEmployeesCmd.Flags().String("ordering", "", "排序顺序(asc / desc)") + + listUnactiveEmployeesCmd := &cobra.Command{ + Use: "list-unactive", + Short: "查询未激活的员工列表", + Long: `查询指定部门下未激活的员工列表,返回员工总数及员工列表。 +必填: + --dept-id 部门 ID +可选: + --staff-id 员工 staffId + --name 员工姓名 + --mobile 手机号 + --job-number 工号 + --main-dept-id 主部门 ID + --login-id-type 登录类型 + --exclusive-account 是否独占账号(true/false) + --send-active-sms 是否发送激活短信(true/false) + --staff-ids 批量操作的 staffId 列表,逗号分隔 + --target-dept-id 变更目标部门 ID + --offset / --size 分页参数 + --order-field 排序字段 + --ordering 排序顺序(asc / desc)`, + Example: ` dws college-contact employee list-unactive --dept-id 12345 + dws college-contact employee list-unactive --dept-id 12345 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 int64 + deptIDRaw, _ := cmd.Flags().GetString("dept-id") + deptIDRaw = strings.TrimSpace(deptIDRaw) + if deptIDRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptID, err := strconv.ParseInt(deptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input["deptId"] = deptID + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"main-dept-id", "mainDeptId"}, + {"target-dept-id", "targetDeptId"}, + {"offset", "offset"}, + {"size", "size"}, + } { + if raw, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(raw) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + for _, pair := range []struct { + flag string + key string + }{ + {"exclusive-account", "exclusiveAccount"}, + {"send-active-sms", "sendActiveSms"}, + } { + if cmd.Flags().Changed(pair.flag) { + raw, _ := cmd.Flags().GetString(pair.flag) + v, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("--%s 须为 true 或 false: %w", pair.flag, err) + } + input[pair.key] = v + } + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"staff-id", "staffId"}, + {"name", "name"}, + {"mobile", "mobile"}, + {"job-number", "jobNumber"}, + {"login-id-type", "loginIdType"}, + {"order-field", "orderField"}, + {"ordering", "ordering"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + // 可选 []string: --staff-ids + if raw, _ := cmd.Flags().GetString("staff-ids"); strings.TrimSpace(raw) != "" { + var staffIDs []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIDs = append(staffIDs, part) + } + } + if len(staffIDs) > 0 { + input["staffIds"] = staffIDs + } + } + + return callMCPToolOnServer("college-contact", "list_unactive_employees", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(listUnactiveEmployeesCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "list_unactive_employees", + CanonicalPath: "college-contact.list_unactive_employees", + CLIPath: "college-contact employee list-unactive", + PrimaryCLIPath: "college-contact employee list-unactive", + }, + Description: "查询未激活的员工列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "list_unactive_employees"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询未激活的员工列表", + UseWhen: []string{"需要查询未激活的员工列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee list-unactive --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(false)}, + {Name: "job-number", Property: "input.jobNumber", Required: boolPtr(false)}, + {Name: "main-dept-id", Property: "input.mainDeptId", Required: boolPtr(false)}, + {Name: "login-id-type", Property: "input.loginIdType", Required: boolPtr(false)}, + {Name: "exclusive-account", Property: "input.exclusiveAccount", Required: boolPtr(false)}, + {Name: "send-active-sms", Property: "input.sendActiveSms", Required: boolPtr(false)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(false)}, + {Name: "target-dept-id", Property: "input.targetDeptId", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + {Name: "order-field", Property: "input.orderField", Required: boolPtr(false)}, + {Name: "ordering", Property: "input.ordering", Required: boolPtr(false)}, + }, + }, + }) + + listUnactiveEmployeesCmd.Flags().String("dept-id", "", "部门 ID(必填)") + listUnactiveEmployeesCmd.Flags().String("staff-id", "", "员工 staffId") + listUnactiveEmployeesCmd.Flags().String("name", "", "员工姓名") + listUnactiveEmployeesCmd.Flags().String("mobile", "", "手机号") + listUnactiveEmployeesCmd.Flags().String("job-number", "", "工号") + listUnactiveEmployeesCmd.Flags().String("main-dept-id", "", "主部门 ID") + listUnactiveEmployeesCmd.Flags().String("login-id-type", "", "登录类型") + listUnactiveEmployeesCmd.Flags().String("exclusive-account", "", "是否独占账号(true/false)") + listUnactiveEmployeesCmd.Flags().String("send-active-sms", "", "是否发送激活短信(true/false)") + listUnactiveEmployeesCmd.Flags().String("staff-ids", "", "批量操作的 staffId 列表,逗号分隔") + listUnactiveEmployeesCmd.Flags().String("target-dept-id", "", "变更目标部门 ID") + listUnactiveEmployeesCmd.Flags().String("offset", "", "分页偏移量") + listUnactiveEmployeesCmd.Flags().String("size", "", "分页大小") + listUnactiveEmployeesCmd.Flags().String("order-field", "", "排序字段") + listUnactiveEmployeesCmd.Flags().String("ordering", "", "排序顺序(asc / desc)") + + deptCmd.AddCommand(getStandardStructureCmd, getDeptDetailCmd, getDeptChainCmd, searchContactCmd, createDeptCmd, updateDeptCmd, deleteDeptCmd, batchUpdateDeptTypeCmd) + + // ════════════════════════════════════════════════════════════ + // dept 子命令组 - 部门管理 + // ════════════════════════════════════════════════════════════ + + overviewStatisticsCmd := &cobra.Command{ + Use: "overview", + Short: "查询高校概览统计", + Long: `查询高校概览统计,返回学生数、教师数、全校人数、未激活员工数、未接受邀请员工数、院系数、行政班数。 +所有参数均可选: + --dept-id 按部门 ID 过滤 + --staff-id 按员工 staffId 过滤 + --keyword 按关键词搜索 + --offset / --size 分页参数`, + Example: ` dws college-contact dept overview + dws college-contact dept overview --dept-id 12345 + dws college-contact dept overview --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + if v, _ := cmd.Flags().GetString("dept-id"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input["deptId"] = n + } + if v, _ := cmd.Flags().GetString("staff-id"); strings.TrimSpace(v) != "" { + input["staffId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("keyword"); strings.TrimSpace(v) != "" { + input["keyword"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("offset"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--offset 须为整数: %w", err) + } + input["offset"] = n + } + if v, _ := cmd.Flags().GetString("size"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--size 须为整数: %w", err) + } + input["size"] = n + } + return callMCPToolOnServer("college-contact", "get_college_overview_statistics", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(overviewStatisticsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_college_overview_statistics", + CanonicalPath: "college-contact.get_college_overview_statistics", + CLIPath: "college-contact dept overview", + PrimaryCLIPath: "college-contact dept overview", + }, + Description: "查询高校概览统计", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_college_overview_statistics"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询高校概览统计", + UseWhen: []string{"需要查询高校概览统计时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact dept overview --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "keyword", Property: "input.keyword", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + overviewStatisticsCmd.Flags().String("dept-id", "", "部门 ID") + overviewStatisticsCmd.Flags().String("staff-id", "", "员工 staffId") + overviewStatisticsCmd.Flags().String("keyword", "", "搜索关键词") + overviewStatisticsCmd.Flags().String("offset", "", "分页偏移量") + overviewStatisticsCmd.Flags().String("size", "", "分页大小") + + deptCmd.AddCommand(overviewStatisticsCmd) + + upgradeStatusCmd := &cobra.Command{ + Use: "upgrade-status", + Short: "查询升级状态", + Long: `查询高校升级状态,返回升级状态码及描述信息。 +所有参数均可选: + --dept-id 按部门 ID 过滤 + --staff-id 按员工 staffId 过滤 + --keyword 按关键词搜索 + --offset / --size 分页参数`, + Example: ` dws college-contact employee upgrade-status + dws college-contact employee upgrade-status --dept-id 12345 + dws college-contact employee upgrade-status --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + if v, _ := cmd.Flags().GetString("dept-id"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input["deptId"] = n + } + if v, _ := cmd.Flags().GetString("staff-id"); strings.TrimSpace(v) != "" { + input["staffId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("keyword"); strings.TrimSpace(v) != "" { + input["keyword"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("offset"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--offset 须为整数: %w", err) + } + input["offset"] = n + } + if v, _ := cmd.Flags().GetString("size"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--size 须为整数: %w", err) + } + input["size"] = n + } + return callMCPToolOnServer("college-contact", "get_college_upgrade_status", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(upgradeStatusCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_college_upgrade_status", + CanonicalPath: "college-contact.get_college_upgrade_status", + CLIPath: "college-contact employee upgrade-status", + PrimaryCLIPath: "college-contact employee upgrade-status", + }, + Description: "查询升级状态", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_college_upgrade_status"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询升级状态", + UseWhen: []string{"需要查询升级状态时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee upgrade-status --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "keyword", Property: "input.keyword", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + upgradeStatusCmd.Flags().String("dept-id", "", "部门 ID") + upgradeStatusCmd.Flags().String("staff-id", "", "员工 staffId") + upgradeStatusCmd.Flags().String("keyword", "", "搜索关键词") + upgradeStatusCmd.Flags().String("offset", "", "分页偏移量") + upgradeStatusCmd.Flags().String("size", "", "分页大小") + + startUpgradeCmd := &cobra.Command{ + Use: "start-upgrade", + Short: "启动升级", + Long: `启动高校通讯录升级,返回操作是否成功。`, + Example: ` dws college-contact employee start-upgrade + dws college-contact employee start-upgrade -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("college-contact", "start_upgrade", map[string]any{ + "input": "", + }) + }, + } + + DeclareLeafMetadata(startUpgradeCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "start_upgrade", + CanonicalPath: "college-contact.start_upgrade", + CLIPath: "college-contact employee start-upgrade", + PrimaryCLIPath: "college-contact employee start-upgrade", + }, + Description: "启动升级", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "start_upgrade"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "启动升级", + UseWhen: []string{"需要启动升级时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee start-upgrade --help"}, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + // ════════════════════════════════════════════════════════════ + // employee 子命令组 — 员工管理 + // ════════════════════════════════════════════════════════════ + + employeeCmd := &cobra.Command{Use: "employee", Short: "员工管理", RunE: groupRunE} + + getEmployeeDetailCmd := &cobra.Command{ + Use: "get-detail", + Short: "查询员工详情", + Long: `查询员工详情,返回员工基本信息、主部门、所在部门列表、账号状态等。 +必填: + --staff-id 员工 staffId +可选: + --dept-id 部门 ID + --name 员工姓名 + --mobile 手机号 + --job-number 工号 + --emp-type 员工类型(college_student / college_teacher) + --main-dept-id 主部门 ID + --login-id-type 登录类型 + --exclusive-account 是否独占账号(true/false) + --send-active-sms 是否发送激活短信(true/false) + --staff-ids 批量操作的 staffId 列表,逗号分隔 + --target-dept-id 变更目标部门 ID + --offset / --size 分页参数 + --order-field 排序字段 + --ordering 排序顺序(asc / desc)`, + Example: ` dws college-contact employee get-detail --staff-id S12345 + dws college-contact employee get-detail --staff-id S12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 string + staffID, _ := cmd.Flags().GetString("staff-id") + staffID = strings.TrimSpace(staffID) + if staffID == "" { + return fmt.Errorf("--staff-id 为必填参数") + } + input["staffId"] = staffID + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"dept-id", "deptId"}, + {"main-dept-id", "mainDeptId"}, + {"target-dept-id", "targetDeptId"}, + {"offset", "offset"}, + {"size", "size"}, + } { + if raw, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(raw) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + for _, pair := range []struct { + flag string + key string + }{ + {"exclusive-account", "exclusiveAccount"}, + {"send-active-sms", "sendActiveSms"}, + } { + if cmd.Flags().Changed(pair.flag) { + raw, _ := cmd.Flags().GetString(pair.flag) + v, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("--%s 须为 true 或 false: %w", pair.flag, err) + } + input[pair.key] = v + } + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"name", "name"}, + {"mobile", "mobile"}, + {"job-number", "jobNumber"}, + {"emp-type", "empType"}, + {"login-id-type", "loginIdType"}, + {"order-field", "orderField"}, + {"ordering", "ordering"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + // 可选 []string: --staff-ids + if raw, _ := cmd.Flags().GetString("staff-ids"); strings.TrimSpace(raw) != "" { + var staffIDs []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIDs = append(staffIDs, part) + } + } + if len(staffIDs) > 0 { + input["staffIds"] = staffIDs + } + } + + return callMCPToolOnServer("college-contact", "get_employee_detail", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(getEmployeeDetailCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_employee_detail", + CanonicalPath: "college-contact.get_employee_detail", + CLIPath: "college-contact employee get-detail", + PrimaryCLIPath: "college-contact employee get-detail", + }, + Description: "查询员工详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_employee_detail"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询员工详情", + UseWhen: []string{"需要查询员工详情时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee get-detail --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(true)}, + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(false)}, + {Name: "job-number", Property: "input.jobNumber", Required: boolPtr(false)}, + {Name: "emp-type", Property: "input.empType", Required: boolPtr(false)}, + {Name: "main-dept-id", Property: "input.mainDeptId", Required: boolPtr(false)}, + {Name: "login-id-type", Property: "input.loginIdType", Required: boolPtr(false)}, + {Name: "exclusive-account", Property: "input.exclusiveAccount", Required: boolPtr(false)}, + {Name: "send-active-sms", Property: "input.sendActiveSms", Required: boolPtr(false)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(false)}, + {Name: "target-dept-id", Property: "input.targetDeptId", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + {Name: "order-field", Property: "input.orderField", Required: boolPtr(false)}, + {Name: "ordering", Property: "input.ordering", Required: boolPtr(false)}, + }, + }, + }) + + getEmployeeDetailCmd.Flags().String("staff-id", "", "员工 staffId(必填)") + getEmployeeDetailCmd.Flags().String("dept-id", "", "部门 ID") + getEmployeeDetailCmd.Flags().String("name", "", "员工姓名") + getEmployeeDetailCmd.Flags().String("mobile", "", "手机号") + getEmployeeDetailCmd.Flags().String("job-number", "", "工号") + getEmployeeDetailCmd.Flags().String("emp-type", "", "员工类型(college_student / college_teacher)") + getEmployeeDetailCmd.Flags().String("main-dept-id", "", "主部门 ID") + getEmployeeDetailCmd.Flags().String("login-id-type", "", "登录类型") + getEmployeeDetailCmd.Flags().String("exclusive-account", "", "是否独占账号(true/false)") + getEmployeeDetailCmd.Flags().String("send-active-sms", "", "是否发送激活短信(true/false)") + getEmployeeDetailCmd.Flags().String("staff-ids", "", "批量操作的 staffId 列表,逗号分隔") + getEmployeeDetailCmd.Flags().String("target-dept-id", "", "变更目标部门 ID") + getEmployeeDetailCmd.Flags().String("offset", "", "分页偏移量") + getEmployeeDetailCmd.Flags().String("size", "", "分页大小") + getEmployeeDetailCmd.Flags().String("order-field", "", "排序字段") + getEmployeeDetailCmd.Flags().String("ordering", "", "排序顺序(asc / desc)") + + addEmployeeCmd := &cobra.Command{ + Use: "add", + Short: "添加员工", + Long: `添加高校员工,返回添加成功/失败数量及邮箱初始密码映射。 +必填: + --emp-type 员工类型(college_student / college_teacher) + --main-dept-id 主部门 ID + --exclusive-account 是否独占账号(true/false) +可选: + --dept-id 部门 ID + --staff-id 员工 staffId + --name 员工姓名 + --mobile 手机号 + --job-number 工号 + --login-id-type 登录类型 + --send-active-sms 是否发送激活短信(true/false) + --staff-ids 批量操作的 staffId 列表,逗号分隔 + --target-dept-id 变更目标部门 ID + --offset / --size 分页参数 + --order-field 排序字段 + --ordering 排序顺序(asc / desc)`, + Example: ` dws college-contact employee add --emp-type college_student --main-dept-id 100 --exclusive-account true --name 张三 --mobile 13800138000 + dws college-contact employee add --emp-type college_teacher --main-dept-id 100 --exclusive-account false --name 李老师 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 string + empType, _ := cmd.Flags().GetString("emp-type") + empType = strings.TrimSpace(empType) + if empType == "" { + return fmt.Errorf("--emp-type 为必填参数") + } + input["empType"] = empType + + // 必填 int64 + mainDeptIDRaw, _ := cmd.Flags().GetString("main-dept-id") + mainDeptIDRaw = strings.TrimSpace(mainDeptIDRaw) + if mainDeptIDRaw == "" { + return fmt.Errorf("--main-dept-id 为必填参数") + } + mainDeptID, err := strconv.ParseInt(mainDeptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--main-dept-id 须为整数: %w", err) + } + input["mainDeptId"] = mainDeptID + + // 必填 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + if !cmd.Flags().Changed("exclusive-account") { + return fmt.Errorf("--exclusive-account 为必填参数(true/false)") + } + eaRaw, _ := cmd.Flags().GetString("exclusive-account") + exclusiveAccount, err := strconv.ParseBool(strings.TrimSpace(eaRaw)) + if err != nil { + return fmt.Errorf("--exclusive-account 须为 true 或 false: %w", err) + } + input["exclusiveAccount"] = exclusiveAccount + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"dept-id", "deptId"}, + {"target-dept-id", "targetDeptId"}, + {"offset", "offset"}, + {"size", "size"}, + } { + if raw, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(raw) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + if cmd.Flags().Changed("send-active-sms") { + sasRaw, _ := cmd.Flags().GetString("send-active-sms") + v, err := strconv.ParseBool(strings.TrimSpace(sasRaw)) + if err != nil { + return fmt.Errorf("--send-active-sms 须为 true 或 false: %w", err) + } + input["sendActiveSms"] = v + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"staff-id", "staffId"}, + {"name", "name"}, + {"mobile", "mobile"}, + {"job-number", "jobNumber"}, + {"login-id-type", "loginIdType"}, + {"order-field", "orderField"}, + {"ordering", "ordering"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + // 可选 []string: --staff-ids + if raw, _ := cmd.Flags().GetString("staff-ids"); strings.TrimSpace(raw) != "" { + var staffIDs []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIDs = append(staffIDs, part) + } + } + if len(staffIDs) > 0 { + input["staffIds"] = staffIDs + } + } + + return callMCPToolOnServer("college-contact", "add_employee", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(addEmployeeCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "add_employee", + CanonicalPath: "college-contact.add_employee", + CLIPath: "college-contact employee add", + PrimaryCLIPath: "college-contact employee add", + }, + Description: "添加员工", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "add_employee"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "添加员工", + UseWhen: []string{"需要添加员工时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee add --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "emp-type", Property: "input.empType", Required: boolPtr(true)}, + {Name: "main-dept-id", Property: "input.mainDeptId", Required: boolPtr(true)}, + {Name: "exclusive-account", Property: "input.exclusiveAccount", Required: boolPtr(true)}, + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(false)}, + {Name: "job-number", Property: "input.jobNumber", Required: boolPtr(false)}, + {Name: "login-id-type", Property: "input.loginIdType", Required: boolPtr(false)}, + {Name: "send-active-sms", Property: "input.sendActiveSms", Required: boolPtr(false)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(false)}, + {Name: "target-dept-id", Property: "input.targetDeptId", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + {Name: "order-field", Property: "input.orderField", Required: boolPtr(false)}, + {Name: "ordering", Property: "input.ordering", Required: boolPtr(false)}, + }, + }, + }) + + addEmployeeCmd.Flags().String("emp-type", "", "员工类型(必填,college_student / college_teacher)") + addEmployeeCmd.Flags().String("main-dept-id", "", "主部门 ID(必填)") + addEmployeeCmd.Flags().String("exclusive-account", "", "是否独占账号(必填,true/false)") + addEmployeeCmd.Flags().String("dept-id", "", "部门 ID") + addEmployeeCmd.Flags().String("staff-id", "", "员工 staffId") + addEmployeeCmd.Flags().String("name", "", "员工姓名") + addEmployeeCmd.Flags().String("mobile", "", "手机号") + addEmployeeCmd.Flags().String("job-number", "", "工号") + addEmployeeCmd.Flags().String("login-id-type", "", "登录类型") + addEmployeeCmd.Flags().String("send-active-sms", "", "是否发送激活短信(true/false)") + addEmployeeCmd.Flags().String("staff-ids", "", "批量操作的 staffId 列表,逗号分隔") + addEmployeeCmd.Flags().String("target-dept-id", "", "变更目标部门 ID") + addEmployeeCmd.Flags().String("offset", "", "分页偏移量") + addEmployeeCmd.Flags().String("size", "", "分页大小") + addEmployeeCmd.Flags().String("order-field", "", "排序字段") + addEmployeeCmd.Flags().String("ordering", "", "排序顺序(asc / desc)") + + removeEmployeeCmd := &cobra.Command{ + Use: "remove", + Short: "移除员工", + Long: `移除高校员工,返回移除成功/失败数量。 +必填: + --staff-ids 员工 staffId 列表,逗号分隔 +可选: + --dept-id 部门 ID + --staff-id 员工 staffId + --name 员工姓名 + --mobile 手机号 + --job-number 工号 + --emp-type 员工类型(college_student / college_teacher) + --main-dept-id 主部门 ID + --login-id-type 登录类型 + --exclusive-account 是否独占账号(true/false) + --send-active-sms 是否发送激活短信(true/false) + --target-dept-id 变更目标部门 ID + --offset / --size 分页参数 + --order-field 排序字段 + --ordering 排序顺序(asc / desc) + +注意:移除操作不可逆。非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact employee remove --staff-ids S12345,S12346 --dry-run + dws college-contact employee remove --staff-ids S12345 -y + dws college-contact employee remove --staff-ids S12345,S12346 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 []string: --staff-ids + raw, _ := cmd.Flags().GetString("staff-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--staff-ids 为必填参数") + } + var staffIDs []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIDs = append(staffIDs, part) + } + } + if len(staffIDs) == 0 { + return fmt.Errorf("--staff-ids 不能为空列表") + } + input["staffIds"] = staffIDs + + if err := collegeContactConfirmDestructive(cmd, "移除员工"); err != nil { + return err + } + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"dept-id", "deptId"}, + {"main-dept-id", "mainDeptId"}, + {"target-dept-id", "targetDeptId"}, + {"offset", "offset"}, + {"size", "size"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + for _, pair := range []struct { + flag string + key string + }{ + {"exclusive-account", "exclusiveAccount"}, + {"send-active-sms", "sendActiveSms"}, + } { + if cmd.Flags().Changed(pair.flag) { + raw, _ := cmd.Flags().GetString(pair.flag) + v, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("--%s 须为 true 或 false: %w", pair.flag, err) + } + input[pair.key] = v + } + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"staff-id", "staffId"}, + {"name", "name"}, + {"mobile", "mobile"}, + {"job-number", "jobNumber"}, + {"emp-type", "empType"}, + {"login-id-type", "loginIdType"}, + {"order-field", "orderField"}, + {"ordering", "ordering"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + return callMCPToolOnServer("college-contact", "remove_employee", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(removeEmployeeCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "remove_employee", + CanonicalPath: "college-contact.remove_employee", + CLIPath: "college-contact employee remove", + PrimaryCLIPath: "college-contact employee remove", + }, + Description: "移除员工", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "remove_employee"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "移除员工", + UseWhen: []string{"需要移除员工时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee remove --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(true)}, + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(false)}, + {Name: "job-number", Property: "input.jobNumber", Required: boolPtr(false)}, + {Name: "emp-type", Property: "input.empType", Required: boolPtr(false)}, + {Name: "main-dept-id", Property: "input.mainDeptId", Required: boolPtr(false)}, + {Name: "login-id-type", Property: "input.loginIdType", Required: boolPtr(false)}, + {Name: "exclusive-account", Property: "input.exclusiveAccount", Required: boolPtr(false)}, + {Name: "send-active-sms", Property: "input.sendActiveSms", Required: boolPtr(false)}, + {Name: "target-dept-id", Property: "input.targetDeptId", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + {Name: "order-field", Property: "input.orderField", Required: boolPtr(false)}, + {Name: "ordering", Property: "input.ordering", Required: boolPtr(false)}, + }, + }, + }) + + removeEmployeeCmd.Flags().String("staff-ids", "", "员工 staffId 列表,逗号分隔(必填)") + removeEmployeeCmd.Flags().String("dept-id", "", "部门 ID") + removeEmployeeCmd.Flags().String("staff-id", "", "员工 staffId") + removeEmployeeCmd.Flags().String("name", "", "员工姓名") + removeEmployeeCmd.Flags().String("mobile", "", "手机号") + removeEmployeeCmd.Flags().String("job-number", "", "工号") + removeEmployeeCmd.Flags().String("emp-type", "", "员工类型(college_student / college_teacher)") + removeEmployeeCmd.Flags().String("main-dept-id", "", "主部门 ID") + removeEmployeeCmd.Flags().String("login-id-type", "", "登录类型") + removeEmployeeCmd.Flags().String("exclusive-account", "", "是否独占账号(true/false)") + removeEmployeeCmd.Flags().String("send-active-sms", "", "是否发送激活短信(true/false)") + removeEmployeeCmd.Flags().String("target-dept-id", "", "变更目标部门 ID") + removeEmployeeCmd.Flags().String("offset", "", "分页偏移量") + removeEmployeeCmd.Flags().String("size", "", "分页大小") + removeEmployeeCmd.Flags().String("order-field", "", "排序字段") + removeEmployeeCmd.Flags().String("ordering", "", "排序顺序(asc / desc)") + + changeEmployeeTypeCmd := &cobra.Command{ + Use: "change-type", + Short: "变更员工类型", + Long: `变更高校员工类型,返回变更成功/失败数量。 +必填: + --emp-type 员工类型(college_student / college_teacher) + --staff-id 员工 staffId +可选: + --dept-id 部门 ID + --name 员工姓名 + --mobile 手机号 + --job-number 工号 + --main-dept-id 主部门 ID + --login-id-type 登录类型 + --exclusive-account 是否独占账号(true/false) + --send-active-sms 是否发送激活短信(true/false) + --staff-ids 批量操作的 staffId 列表,逗号分隔 + --target-dept-id 变更目标部门 ID + --offset / --size 分页参数 + --order-field 排序字段 + --ordering 排序顺序(asc / desc)`, + Example: ` dws college-contact employee change-type --staff-id S12345 --emp-type college_teacher + dws college-contact employee change-type --staff-id S12345 --emp-type college_student -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 string + for _, pair := range []struct { + flag string + key string + }{ + {"staff-id", "staffId"}, + {"emp-type", "empType"}, + } { + v, _ := cmd.Flags().GetString(pair.flag) + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("--%s 为必填参数", pair.flag) + } + input[pair.key] = v + } + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"dept-id", "deptId"}, + {"main-dept-id", "mainDeptId"}, + {"target-dept-id", "targetDeptId"}, + {"offset", "offset"}, + {"size", "size"}, + } { + if raw, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(raw) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + for _, pair := range []struct { + flag string + key string + }{ + {"exclusive-account", "exclusiveAccount"}, + {"send-active-sms", "sendActiveSms"}, + } { + if cmd.Flags().Changed(pair.flag) { + raw, _ := cmd.Flags().GetString(pair.flag) + v, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("--%s 须为 true 或 false: %w", pair.flag, err) + } + input[pair.key] = v + } + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"name", "name"}, + {"mobile", "mobile"}, + {"job-number", "jobNumber"}, + {"login-id-type", "loginIdType"}, + {"order-field", "orderField"}, + {"ordering", "ordering"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + // 可选 []string: --staff-ids + if raw, _ := cmd.Flags().GetString("staff-ids"); strings.TrimSpace(raw) != "" { + var staffIDs []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIDs = append(staffIDs, part) + } + } + if len(staffIDs) > 0 { + input["staffIds"] = staffIDs + } + } + + return callMCPToolOnServer("college-contact", "change_employee_type", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(changeEmployeeTypeCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "change_employee_type", + CanonicalPath: "college-contact.change_employee_type", + CLIPath: "college-contact employee change-type", + PrimaryCLIPath: "college-contact employee change-type", + }, + Description: "变更员工类型", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "change_employee_type"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "变更员工类型", + UseWhen: []string{"需要变更员工类型时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee change-type --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(true)}, + {Name: "emp-type", Property: "input.empType", Required: boolPtr(true)}, + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(false)}, + {Name: "job-number", Property: "input.jobNumber", Required: boolPtr(false)}, + {Name: "main-dept-id", Property: "input.mainDeptId", Required: boolPtr(false)}, + {Name: "login-id-type", Property: "input.loginIdType", Required: boolPtr(false)}, + {Name: "exclusive-account", Property: "input.exclusiveAccount", Required: boolPtr(false)}, + {Name: "send-active-sms", Property: "input.sendActiveSms", Required: boolPtr(false)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(false)}, + {Name: "target-dept-id", Property: "input.targetDeptId", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + {Name: "order-field", Property: "input.orderField", Required: boolPtr(false)}, + {Name: "ordering", Property: "input.ordering", Required: boolPtr(false)}, + }, + }, + }) + + changeEmployeeTypeCmd.Flags().String("staff-id", "", "员工 staffId(必填)") + changeEmployeeTypeCmd.Flags().String("emp-type", "", "员工类型(必填,college_student / college_teacher)") + changeEmployeeTypeCmd.Flags().String("dept-id", "", "部门 ID") + changeEmployeeTypeCmd.Flags().String("name", "", "员工姓名") + changeEmployeeTypeCmd.Flags().String("mobile", "", "手机号") + changeEmployeeTypeCmd.Flags().String("job-number", "", "工号") + changeEmployeeTypeCmd.Flags().String("main-dept-id", "", "主部门 ID") + changeEmployeeTypeCmd.Flags().String("login-id-type", "", "登录类型") + changeEmployeeTypeCmd.Flags().String("exclusive-account", "", "是否独占账号(true/false)") + changeEmployeeTypeCmd.Flags().String("send-active-sms", "", "是否发送激活短信(true/false)") + changeEmployeeTypeCmd.Flags().String("staff-ids", "", "批量操作的 staffId 列表,逗号分隔") + changeEmployeeTypeCmd.Flags().String("target-dept-id", "", "变更目标部门 ID") + changeEmployeeTypeCmd.Flags().String("offset", "", "分页偏移量") + changeEmployeeTypeCmd.Flags().String("size", "", "分页大小") + changeEmployeeTypeCmd.Flags().String("order-field", "", "排序字段") + changeEmployeeTypeCmd.Flags().String("ordering", "", "排序顺序(asc / desc)") + + changeEmployeeDeptCmd := &cobra.Command{ + Use: "change-dept", + Short: "变更员工部门", + Long: `变更高校员工所属部门,返回变更成功/失败数量。 +必填: + --staff-id 员工 staffId + --target-dept-id 变更目标部门 ID +可选: + --dept-id 部门 ID + --name 员工姓名 + --mobile 手机号 + --job-number 工号 + --emp-type 员工类型(college_student / college_teacher) + --main-dept-id 主部门 ID + --login-id-type 登录类型 + --exclusive-account 是否独占账号(true/false) + --send-active-sms 是否发送激活短信(true/false) + --staff-ids 批量操作的 staffId 列表,逗号分隔 + --offset / --size 分页参数 + --order-field 排序字段 + --ordering 排序顺序(asc / desc)`, + Example: ` dws college-contact employee change-dept --staff-id S12345 --target-dept-id 200 + dws college-contact employee change-dept --staff-id S12345 --target-dept-id 200 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 string + staffID, _ := cmd.Flags().GetString("staff-id") + staffID = strings.TrimSpace(staffID) + if staffID == "" { + return fmt.Errorf("--staff-id 为必填参数") + } + input["staffId"] = staffID + + // 必填 int64 + targetDeptIDRaw, _ := cmd.Flags().GetString("target-dept-id") + targetDeptIDRaw = strings.TrimSpace(targetDeptIDRaw) + if targetDeptIDRaw == "" { + return fmt.Errorf("--target-dept-id 为必填参数") + } + targetDeptID, err := strconv.ParseInt(targetDeptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--target-dept-id 须为整数: %w", err) + } + input["targetDeptId"] = targetDeptID + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"dept-id", "deptId"}, + {"main-dept-id", "mainDeptId"}, + {"offset", "offset"}, + {"size", "size"}, + } { + if raw, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(raw) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + for _, pair := range []struct { + flag string + key string + }{ + {"exclusive-account", "exclusiveAccount"}, + {"send-active-sms", "sendActiveSms"}, + } { + if cmd.Flags().Changed(pair.flag) { + raw, _ := cmd.Flags().GetString(pair.flag) + v, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("--%s 须为 true 或 false: %w", pair.flag, err) + } + input[pair.key] = v + } + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"name", "name"}, + {"mobile", "mobile"}, + {"job-number", "jobNumber"}, + {"emp-type", "empType"}, + {"login-id-type", "loginIdType"}, + {"order-field", "orderField"}, + {"ordering", "ordering"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + // 可选 []string: --staff-ids + if raw, _ := cmd.Flags().GetString("staff-ids"); strings.TrimSpace(raw) != "" { + var staffIDs []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIDs = append(staffIDs, part) + } + } + if len(staffIDs) > 0 { + input["staffIds"] = staffIDs + } + } + + return callMCPToolOnServer("college-contact", "change_employee_dept", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(changeEmployeeDeptCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "change_employee_dept", + CanonicalPath: "college-contact.change_employee_dept", + CLIPath: "college-contact employee change-dept", + PrimaryCLIPath: "college-contact employee change-dept", + }, + Description: "变更员工部门", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "change_employee_dept"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "变更员工部门", + UseWhen: []string{"需要变更员工部门时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee change-dept --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(true)}, + {Name: "target-dept-id", Property: "input.targetDeptId", Required: boolPtr(true)}, + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(false)}, + {Name: "job-number", Property: "input.jobNumber", Required: boolPtr(false)}, + {Name: "emp-type", Property: "input.empType", Required: boolPtr(false)}, + {Name: "main-dept-id", Property: "input.mainDeptId", Required: boolPtr(false)}, + {Name: "login-id-type", Property: "input.loginIdType", Required: boolPtr(false)}, + {Name: "exclusive-account", Property: "input.exclusiveAccount", Required: boolPtr(false)}, + {Name: "send-active-sms", Property: "input.sendActiveSms", Required: boolPtr(false)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + {Name: "order-field", Property: "input.orderField", Required: boolPtr(false)}, + {Name: "ordering", Property: "input.ordering", Required: boolPtr(false)}, + }, + }, + }) + + changeEmployeeDeptCmd.Flags().String("staff-id", "", "员工 staffId(必填)") + changeEmployeeDeptCmd.Flags().String("target-dept-id", "", "变更目标部门 ID(必填)") + changeEmployeeDeptCmd.Flags().String("dept-id", "", "部门 ID") + changeEmployeeDeptCmd.Flags().String("name", "", "员工姓名") + changeEmployeeDeptCmd.Flags().String("mobile", "", "手机号") + changeEmployeeDeptCmd.Flags().String("job-number", "", "工号") + changeEmployeeDeptCmd.Flags().String("emp-type", "", "员工类型(college_student / college_teacher)") + changeEmployeeDeptCmd.Flags().String("main-dept-id", "", "主部门 ID") + changeEmployeeDeptCmd.Flags().String("login-id-type", "", "登录类型") + changeEmployeeDeptCmd.Flags().String("exclusive-account", "", "是否独占账号(true/false)") + changeEmployeeDeptCmd.Flags().String("send-active-sms", "", "是否发送激活短信(true/false)") + changeEmployeeDeptCmd.Flags().String("staff-ids", "", "批量操作的 staffId 列表,逗号分隔") + changeEmployeeDeptCmd.Flags().String("offset", "", "分页偏移量") + changeEmployeeDeptCmd.Flags().String("size", "", "分页大小") + changeEmployeeDeptCmd.Flags().String("order-field", "", "排序字段") + changeEmployeeDeptCmd.Flags().String("ordering", "", "排序顺序(asc / desc)") + + sendActiveSmsCmd := &cobra.Command{ + Use: "send-active-sms", + Short: "发送激活短信", + Long: `向指定部门下的员工发送激活短信,返回操作是否成功。 +必填: + --dept-id 部门 ID +可选: + --staff-id 员工 staffId + --name 员工姓名 + --mobile 手机号 + --job-number 工号 + --emp-type 员工类型(college_student / college_teacher) + --main-dept-id 主部门 ID + --login-id-type 登录类型 + --exclusive-account 是否独占账号(true/false) + --send-active-sms 是否发送激活短信(true/false) + --staff-ids 批量操作的 staffId 列表,逗号分隔 + --target-dept-id 变更目标部门 ID + --offset / --size 分页参数 + --order-field 排序字段 + --ordering 排序顺序(asc / desc)`, + Example: ` dws college-contact employee send-active-sms --dept-id 100 + dws college-contact employee send-active-sms --dept-id 100 --staff-ids S12345,S12346 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 int64 + deptIDRaw, _ := cmd.Flags().GetString("dept-id") + deptIDRaw = strings.TrimSpace(deptIDRaw) + if deptIDRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptID, err := strconv.ParseInt(deptIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + input["deptId"] = deptID + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"main-dept-id", "mainDeptId"}, + {"target-dept-id", "targetDeptId"}, + {"offset", "offset"}, + {"size", "size"}, + } { + if raw, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(raw) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + for _, pair := range []struct { + flag string + key string + }{ + {"exclusive-account", "exclusiveAccount"}, + {"send-active-sms", "sendActiveSms"}, + } { + if cmd.Flags().Changed(pair.flag) { + raw, _ := cmd.Flags().GetString(pair.flag) + v, err := strconv.ParseBool(strings.TrimSpace(raw)) + if err != nil { + return fmt.Errorf("--%s 须为 true 或 false: %w", pair.flag, err) + } + input[pair.key] = v + } + } + + // 可选 string + for _, pair := range []struct { + flag string + key string + }{ + {"staff-id", "staffId"}, + {"name", "name"}, + {"mobile", "mobile"}, + {"job-number", "jobNumber"}, + {"emp-type", "empType"}, + {"login-id-type", "loginIdType"}, + {"order-field", "orderField"}, + {"ordering", "ordering"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + input[pair.key] = strings.TrimSpace(v) + } + } + + // 可选 []string: --staff-ids + if raw, _ := cmd.Flags().GetString("staff-ids"); strings.TrimSpace(raw) != "" { + var staffIDs []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIDs = append(staffIDs, part) + } + } + if len(staffIDs) > 0 { + input["staffIds"] = staffIDs + } + } + + return callMCPToolOnServer("college-contact", "send_active_sms", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(sendActiveSmsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "send_active_sms", + CanonicalPath: "college-contact.send_active_sms", + CLIPath: "college-contact employee send-active-sms", + PrimaryCLIPath: "college-contact employee send-active-sms", + }, + Description: "发送激活短信", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "send_active_sms"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "发送激活短信", + UseWhen: []string{"需要发送激活短信时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact employee send-active-sms --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(false)}, + {Name: "job-number", Property: "input.jobNumber", Required: boolPtr(false)}, + {Name: "emp-type", Property: "input.empType", Required: boolPtr(false)}, + {Name: "main-dept-id", Property: "input.mainDeptId", Required: boolPtr(false)}, + {Name: "login-id-type", Property: "input.loginIdType", Required: boolPtr(false)}, + {Name: "exclusive-account", Property: "input.exclusiveAccount", Required: boolPtr(false)}, + {Name: "send-active-sms", Property: "input.sendActiveSms", Required: boolPtr(false)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(false)}, + {Name: "target-dept-id", Property: "input.targetDeptId", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + {Name: "order-field", Property: "input.orderField", Required: boolPtr(false)}, + {Name: "ordering", Property: "input.ordering", Required: boolPtr(false)}, + }, + }, + }) + + sendActiveSmsCmd.Flags().String("dept-id", "", "部门 ID(必填)") + sendActiveSmsCmd.Flags().String("staff-id", "", "员工 staffId") + sendActiveSmsCmd.Flags().String("name", "", "员工姓名") + sendActiveSmsCmd.Flags().String("mobile", "", "手机号") + sendActiveSmsCmd.Flags().String("job-number", "", "工号") + sendActiveSmsCmd.Flags().String("emp-type", "", "员工类型(college_student / college_teacher)") + sendActiveSmsCmd.Flags().String("main-dept-id", "", "主部门 ID") + sendActiveSmsCmd.Flags().String("login-id-type", "", "登录类型") + sendActiveSmsCmd.Flags().String("exclusive-account", "", "是否独占账号(true/false)") + sendActiveSmsCmd.Flags().String("send-active-sms", "", "是否发送激活短信(true/false)") + sendActiveSmsCmd.Flags().String("staff-ids", "", "批量操作的 staffId 列表,逗号分隔") + sendActiveSmsCmd.Flags().String("target-dept-id", "", "变更目标部门 ID") + sendActiveSmsCmd.Flags().String("offset", "", "分页偏移量") + sendActiveSmsCmd.Flags().String("size", "", "分页大小") + sendActiveSmsCmd.Flags().String("order-field", "", "排序字段") + sendActiveSmsCmd.Flags().String("ordering", "", "排序顺序(asc / desc)") + + // ════════════════════════════════════════════════════════════ + // alumni 子命令组 — 校友管理 + // ════════════════════════════════════════════════════════════ + + alumniCmd := &cobra.Command{Use: "alumni", Short: "校友管理", RunE: groupRunE} + + getAlumniDeptTreeCmd := &cobra.Command{ + Use: "get-dept-tree", + Short: "查询校友部门树", + Long: `查询校友部门树,返回指定校友部门的下级部门列表。 +必填: + --alumni-dept-id 校友部门 ID`, + Example: ` dws college-contact alumni get-dept-tree --alumni-dept-id 12345 + dws college-contact alumni get-dept-tree --alumni-dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + return callMCPToolOnServer("college-contact", "get_alumni_dept_tree", map[string]any{ + "input": map[string]any{"alumniDeptId": alumniDeptId}, + }) + }, + } + + DeclareLeafMetadata(getAlumniDeptTreeCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_alumni_dept_tree", + CanonicalPath: "college-contact.get_alumni_dept_tree", + CLIPath: "college-contact alumni get-dept-tree", + PrimaryCLIPath: "college-contact alumni get-dept-tree", + }, + Description: "查询校友部门树", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_alumni_dept_tree"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询校友部门树", + UseWhen: []string{"需要查询校友部门树时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni get-dept-tree --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + }, + }, + }) + + getAlumniDeptTreeCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + + getAlumniDeptInfoCmd := &cobra.Command{ + Use: "get-info", + Short: "查询校友部门详情", + Long: `查询校友部门详情,返回部门名称、部门人数、负责人列表、是否有下级部门等。 +必填: + --alumni-dept-id 校友部门 ID`, + Example: ` dws college-contact alumni get-info --alumni-dept-id 12345 + dws college-contact alumni get-info --alumni-dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + return callMCPToolOnServer("college-contact", "get_alumni_dept_info", map[string]any{ + "input": map[string]any{"alumniDeptId": alumniDeptId}, + }) + }, + } + + DeclareLeafMetadata(getAlumniDeptInfoCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_alumni_dept_info", + CanonicalPath: "college-contact.get_alumni_dept_info", + CLIPath: "college-contact alumni get-info", + PrimaryCLIPath: "college-contact alumni get-info", + }, + Description: "查询校友部门详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_alumni_dept_info"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询校友部门详情", + UseWhen: []string{"需要查询校友部门详情时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni get-info --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + }, + }, + }) + + getAlumniDeptInfoCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + + listAlumniCmd := &cobra.Command{ + Use: "list", + Short: "查询校友列表", + Long: `查询指定校友部门下的校友列表,返回校友列表。 +必填: + --alumni-dept-id 校友部门 ID + --order-field 排序字段(如 "dept_entry"、"custom") + --ordering 排序顺序("asc" 或 "desc") +可选: + --offset / --size 分页参数`, + Example: ` dws college-contact alumni list --alumni-dept-id 12345 --order-field dept_entry --ordering asc + dws college-contact alumni list --alumni-dept-id 12345 --order-field dept_entry --ordering asc --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 int64 + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + input["alumniDeptId"] = alumniDeptId + + // 必填 string + for _, flag := range []string{"order-field", "ordering"} { + v, _ := cmd.Flags().GetString(flag) + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("--%s 为必填参数", flag) + } + key := flag + if flag == "order-field" { + key = "orderField" + } + input[key] = v + } + + // 可选 int64 + for _, pair := range []struct { + flag string + key string + }{ + {"offset", "offset"}, + {"size", "size"}, + } { + if v, _ := cmd.Flags().GetString(pair.flag); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", pair.flag, err) + } + input[pair.key] = n + } + } + + return callMCPToolOnServer("college-contact", "list_alumni", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(listAlumniCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "list_alumni", + CanonicalPath: "college-contact.list_alumni", + CLIPath: "college-contact alumni list", + PrimaryCLIPath: "college-contact alumni list", + }, + Description: "查询校友列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "list_alumni"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询校友列表", + UseWhen: []string{"需要查询校友列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni list --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + {Name: "order-field", Property: "input.orderField", Required: boolPtr(true)}, + {Name: "ordering", Property: "input.ordering", Required: boolPtr(true)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + listAlumniCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + listAlumniCmd.Flags().String("order-field", "", "排序字段(必填,如 dept_entry / custom)") + listAlumniCmd.Flags().String("ordering", "", "排序顺序(必填,asc / desc)") + listAlumniCmd.Flags().String("offset", "", "分页偏移量") + listAlumniCmd.Flags().String("size", "", "分页大小") + + queryAlumnusCmd := &cobra.Command{ + Use: "query", + Short: "查询校友详情", + Long: `查询校友详情,返回校友基本信息、入学/毕业时间、工作单位、部门列表等。 +必填: + --staff-id 校友 staffId`, + Example: ` dws college-contact alumni query --staff-id S12345 + dws college-contact alumni query --staff-id S12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + staffId, _ := cmd.Flags().GetString("staff-id") + staffId = strings.TrimSpace(staffId) + if staffId == "" { + return fmt.Errorf("--staff-id 为必填参数") + } + return callMCPToolOnServer("college-contact", "query_alumnus", map[string]any{ + "input": map[string]any{"staffId": staffId}, + }) + }, + } + + DeclareLeafMetadata(queryAlumnusCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "query_alumnus", + CanonicalPath: "college-contact.query_alumnus", + CLIPath: "college-contact alumni query", + PrimaryCLIPath: "college-contact alumni query", + }, + Description: "查询校友详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "query_alumnus"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询校友详情", + UseWhen: []string{"需要查询校友详情时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni query --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(true)}, + }, + }, + }) + + queryAlumnusCmd.Flags().String("staff-id", "", "校友 staffId(必填)") + + // ── search ──────────────────────────────────────────────── + searchAlumniCmd := &cobra.Command{ + Use: "search", + Short: "搜索校友", + Long: `按关键词搜索校友,返回匹配校友列表。 + +参数说明: + --keyword 搜索关键词(必填) + --offset 分页偏移量(可选) + --size 分页大小(可选)`, + Example: ` dws college-contact alumni search --keyword 张三 + dws college-contact alumni search --keyword 计算机 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 string - keyword + keyword, _ := cmd.Flags().GetString("keyword") + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return fmt.Errorf("--keyword 为必填参数") + } + input["keyword"] = keyword + + // 可选 int64 - offset, size + for _, p := range []struct{ flag, key string }{ + {"offset", "offset"}, {"size", "size"}, + } { + if v, _ := cmd.Flags().GetString(p.flag); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", p.flag, err) + } + input[p.key] = n + } + } + + return callMCPToolOnServer("college-contact", "search_alumni", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(searchAlumniCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "search_alumni", + CanonicalPath: "college-contact.search_alumni", + CLIPath: "college-contact alumni search", + PrimaryCLIPath: "college-contact alumni search", + }, + Description: "搜索校友", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "search_alumni"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "搜索校友", + UseWhen: []string{"需要搜索校友时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni search --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "keyword", Property: "input.keyword", Required: boolPtr(true)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + searchAlumniCmd.Flags().String("keyword", "", "搜索关键词(必填)") + searchAlumniCmd.Flags().String("offset", "", "分页偏移量(可选)") + searchAlumniCmd.Flags().String("size", "", "分页大小(可选)") + + // ── list-unaccepted ─────────────────────────────────────── + listUnacceptedAlumnusCmd := &cobra.Command{ + Use: "list-unaccepted", + Short: "查询未接受邀请的校友列表", + Long: `查询指定校友部门下未接受邀请的校友列表,返回校友列表及总数。 + +参数说明: + --alumni-dept-id 校友部门 ID(必填) + --offset 分页偏移量(可选) + --size 分页大小(可选)`, + Example: ` dws college-contact alumni list-unaccepted --alumni-dept-id 12345 + dws college-contact alumni list-unaccepted --alumni-dept-id 12345 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 int64 - alumniDeptId + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + input["alumniDeptId"] = alumniDeptId + + // 可选 int64 - offset, size + for _, p := range []struct{ flag, key string }{ + {"offset", "offset"}, {"size", "size"}, + } { + if v, _ := cmd.Flags().GetString(p.flag); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", p.flag, err) + } + input[p.key] = n + } + } + + return callMCPToolOnServer("college-contact", "query_unaccepted_alumnus", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(listUnacceptedAlumnusCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "query_unaccepted_alumnus", + CanonicalPath: "college-contact.query_unaccepted_alumnus", + CLIPath: "college-contact alumni list-unaccepted", + PrimaryCLIPath: "college-contact alumni list-unaccepted", + }, + Description: "查询未接受邀请的校友列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "query_unaccepted_alumnus"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询未接受邀请的校友列表", + UseWhen: []string{"需要查询未接受邀请的校友列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni list-unaccepted --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + listUnacceptedAlumnusCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + listUnacceptedAlumnusCmd.Flags().String("offset", "", "分页偏移量(可选)") + listUnacceptedAlumnusCmd.Flags().String("size", "", "分页大小(可选)") + + // ── get-group ───────────────────────────────────────────── + getAlumniGroupCmd := &cobra.Command{ + Use: "get-group", + Short: "查询校友群", + Long: `查询指定校友部门的校友群信息,返回群名称、群 CID、群主信息等。 + +必填: + --alumni-dept-id 校友部门 ID`, + Example: ` dws college-contact alumni get-group --alumni-dept-id 12345 + dws college-contact alumni get-group --alumni-dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + return callMCPToolOnServer("college-contact", "get_alumni_group", map[string]any{ + "input": map[string]any{"alumniDeptId": alumniDeptId}, + }) + }, + } + + DeclareLeafMetadata(getAlumniGroupCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_alumni_group", + CanonicalPath: "college-contact.get_alumni_group", + CLIPath: "college-contact alumni get-group", + PrimaryCLIPath: "college-contact alumni get-group", + }, + Description: "查询校友群", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_alumni_group"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询校友群", + UseWhen: []string{"需要查询校友群时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni get-group --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + }, + }, + }) + + getAlumniGroupCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + + // ── create-dept ─────────────────────────────────────────── + createAlumniDeptsCmd := &cobra.Command{ + Use: "create-dept", + Short: "创建校友子部门", + Long: `在指定校友部门下创建子部门,返回创建的部门列表。 + +必填: + --alumni-dept-id 父校友部门 ID + --dept-name 新部门名称`, + Example: ` dws college-contact alumni create-dept --alumni-dept-id 12345 --dept-name 2020级校友 + dws college-contact alumni create-dept --alumni-dept-id 12345 --dept-name 2020级校友 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + deptName, _ := cmd.Flags().GetString("dept-name") + deptName = strings.TrimSpace(deptName) + if deptName == "" { + return fmt.Errorf("--dept-name 为必填参数") + } + return callMCPToolOnServer("college-contact", "create_alumni_depts", map[string]any{ + "input": map[string]any{"alumniDeptId": alumniDeptId, "deptName": deptName}, + }) + }, + } + + DeclareLeafMetadata(createAlumniDeptsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "create_alumni_depts", + CanonicalPath: "college-contact.create_alumni_depts", + CLIPath: "college-contact alumni create-dept", + PrimaryCLIPath: "college-contact alumni create-dept", + }, + Description: "创建校友子部门", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "create_alumni_depts"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建校友子部门", + UseWhen: []string{"需要创建校友子部门时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni create-dept --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + {Name: "dept-name", Property: "input.deptName", Required: boolPtr(true)}, + }, + }, + }) + + createAlumniDeptsCmd.Flags().String("alumni-dept-id", "", "父校友部门 ID(必填)") + createAlumniDeptsCmd.Flags().String("dept-name", "", "新部门名称(必填)") + + // ── update-dept ─────────────────────────────────────────── + updateAlumniDeptCmd := &cobra.Command{ + Use: "update-dept", + Short: "更新校友部门", + Long: `更新校友部门名称。 + +必填: + --alumni-dept-id 校友部门 ID + --dept-name 新部门名称`, + Example: ` dws college-contact alumni update-dept --alumni-dept-id 12345 --dept-name 2021级校友 + dws college-contact alumni update-dept --alumni-dept-id 12345 --dept-name 2021级校友 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + deptName, _ := cmd.Flags().GetString("dept-name") + deptName = strings.TrimSpace(deptName) + if deptName == "" { + return fmt.Errorf("--dept-name 为必填参数") + } + return callMCPToolOnServer("college-contact", "update_alumni_dept", map[string]any{ + "input": map[string]any{"alumniDeptId": alumniDeptId, "deptName": deptName}, + }) + }, + } + + DeclareLeafMetadata(updateAlumniDeptCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "update_alumni_dept", + CanonicalPath: "college-contact.update_alumni_dept", + CLIPath: "college-contact alumni update-dept", + PrimaryCLIPath: "college-contact alumni update-dept", + }, + Description: "更新校友部门", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "update_alumni_dept"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "更新校友部门", + UseWhen: []string{"需要更新校友部门时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni update-dept --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + {Name: "dept-name", Property: "input.deptName", Required: boolPtr(true)}, + }, + }, + }) + + updateAlumniDeptCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + updateAlumniDeptCmd.Flags().String("dept-name", "", "新部门名称(必填)") + + // ── delete-dept ─────────────────────────────────────────── + deleteAlumniDeptCmd := &cobra.Command{ + Use: "delete-dept", + Short: "删除校友部门", + Long: `删除指定校友部门。注意:删除操作不可逆。 + +必填: + --alumni-dept-id 校友部门 ID + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact alumni delete-dept --alumni-dept-id 12345 --dry-run + dws college-contact alumni delete-dept --alumni-dept-id 12345 -y + dws college-contact alumni delete-dept --alumni-dept-id 12345 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + if err := collegeContactConfirmDestructive(cmd, "删除校友部门"); err != nil { + return err + } + return callMCPToolOnServer("college-contact", "delete_alumni_dept", map[string]any{ + "input": map[string]any{"alumniDeptId": alumniDeptId}, + }) + }, + } + + DeclareLeafMetadata(deleteAlumniDeptCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "delete_alumni_dept", + CanonicalPath: "college-contact.delete_alumni_dept", + CLIPath: "college-contact alumni delete-dept", + PrimaryCLIPath: "college-contact alumni delete-dept", + }, + Description: "删除校友部门", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "delete_alumni_dept"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "删除校友部门", + UseWhen: []string{"需要删除校友部门时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni delete-dept --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + }, + }, + }) + + deleteAlumniDeptCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + + // ── update-managers ─────────────────────────────────────── + updateAlumniDeptManagersCmd := &cobra.Command{ + Use: "update-managers", + Short: "设置校友部门负责人", + Long: `设置指定校友部门的负责人(管理员)列表。 +必填: + --alumni-dept-id 校友部门 ID + --admin-user-ids 管理员 userId 列表,逗号分隔`, + Example: ` dws college-contact alumni update-managers --alumni-dept-id 12345 --admin-user-ids user001,user002 + dws college-contact alumni update-managers --alumni-dept-id 12345 --admin-user-ids user001 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + + raw, _ := cmd.Flags().GetString("admin-user-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--admin-user-ids 为必填参数") + } + var adminUserIds []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + adminUserIds = append(adminUserIds, part) + } + } + if len(adminUserIds) == 0 { + return fmt.Errorf("--admin-user-ids 不能为空列表") + } + + return callMCPToolOnServer("college-contact", "update_alumni_dept_managers", map[string]any{ + "input": map[string]any{ + "alumniDeptId": alumniDeptId, + "adminUserIds": adminUserIds, + }, + }) + }, + } + + DeclareLeafMetadata(updateAlumniDeptManagersCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "update_alumni_dept_managers", + CanonicalPath: "college-contact.update_alumni_dept_managers", + CLIPath: "college-contact alumni update-managers", + PrimaryCLIPath: "college-contact alumni update-managers", + }, + Description: "设置校友部门负责人", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "update_alumni_dept_managers"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "设置校友部门负责人", + UseWhen: []string{"需要设置校友部门负责人时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni update-managers --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + {Name: "admin-user-ids", Property: "input.adminUserIds", Required: boolPtr(true)}, + }, + }, + }) + + updateAlumniDeptManagersCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + updateAlumniDeptManagersCmd.Flags().String("admin-user-ids", "", "管理员 userId 列表,逗号分隔(必填)") + + // ── add-alumnus ─────────────────────────────────────────── + addAlumnusCmd := &cobra.Command{ + Use: "add-alumnus", + Short: "添加校友", + Long: `添加校友到指定校友部门,返回添加是否成功。 +必填: + --name 校友姓名 + --mobile 手机号 + --dept-ids 校友部门 ID 列表,逗号分隔 +可选: + --student-number 学号 + --email 邮箱 + --intake 入学年份 + --outtake 毕业年份`, + Example: ` dws college-contact alumni add-alumnus --name 张三 --mobile 13800138000 --dept-ids 12345,67890 + dws college-contact alumni add-alumnus --name 张三 --mobile 13800138000 --dept-ids 12345 --student-number 2020001 --intake 2020 --outtake 2024 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 string + for _, p := range []struct{ flag, key string }{ + {"name", "name"}, {"mobile", "mobile"}, + } { + v, _ := cmd.Flags().GetString(p.flag) + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("--%s 为必填参数", p.flag) + } + input[p.key] = v + } + + // 必填 []int64: --dept-ids + raw, _ := cmd.Flags().GetString("dept-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--dept-ids 为必填参数") + } + var deptIds []int64 + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return fmt.Errorf("--dept-ids 元素须为整数: %w", err) + } + deptIds = append(deptIds, n) + } + if len(deptIds) == 0 { + return fmt.Errorf("--dept-ids 不能为空列表") + } + input["deptIds"] = deptIds + + // 可选 string + for _, p := range []struct{ flag, key string }{ + {"student-number", "studentNumber"}, + {"email", "email"}, + {"intake", "intake"}, + {"outtake", "outtake"}, + } { + if v, _ := cmd.Flags().GetString(p.flag); strings.TrimSpace(v) != "" { + input[p.key] = strings.TrimSpace(v) + } + } + + return callMCPToolOnServer("college-contact", "add_alumnus", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(addAlumnusCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "add_alumnus", + CanonicalPath: "college-contact.add_alumnus", + CLIPath: "college-contact alumni add-alumnus", + PrimaryCLIPath: "college-contact alumni add-alumnus", + }, + Description: "添加校友", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "add_alumnus"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "添加校友", + UseWhen: []string{"需要添加校友时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni add-alumnus --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(false)}, + {Name: "dept-ids", Property: "input.deptIds", Required: boolPtr(true)}, + {Name: "student-number", Property: "input.studentNumber", Required: boolPtr(false)}, + {Name: "email", Property: "input.email", Required: boolPtr(false)}, + {Name: "intake", Property: "input.intake", Required: boolPtr(false)}, + {Name: "outtake", Property: "input.outtake", Required: boolPtr(false)}, + }, + }, + }) + + addAlumnusCmd.Flags().String("name", "", "校友姓名(必填)") + addAlumnusCmd.Flags().String("mobile", "", "手机号(必填)") + addAlumnusCmd.Flags().String("dept-ids", "", "校友部门 ID 列表,逗号分隔(必填)") + addAlumnusCmd.Flags().String("student-number", "", "学号") + addAlumnusCmd.Flags().String("email", "", "邮箱") + addAlumnusCmd.Flags().String("intake", "", "入学年份") + addAlumnusCmd.Flags().String("outtake", "", "毕业年份") + + // ── update-alumnus ──────────────────────────────────────── + updateAlumnusCmd := &cobra.Command{ + Use: "update-alumnus", + Short: "更新校友信息", + Long: `更新校友信息,返回更新是否成功。 +必填: + --staff-id 校友 staffId + --name 校友姓名 + --dept-ids 校友部门 ID 列表,逗号分隔 +可选: + --student-number 学号 + --email 邮箱 + --intake 入学年份 + --outtake 毕业年份`, + Example: ` dws college-contact alumni update-alumnus --staff-id staff001 --name 张三 --dept-ids 12345,67890 + dws college-contact alumni update-alumnus --staff-id staff001 --name 张三 --dept-ids 12345 --student-number 2020001 --intake 2020 --outtake 2024 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 string + for _, p := range []struct{ flag, key string }{ + {"staff-id", "staffId"}, {"name", "name"}, + } { + v, _ := cmd.Flags().GetString(p.flag) + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("--%s 为必填参数", p.flag) + } + input[p.key] = v + } + + // 必填 []int64: --dept-ids + raw, _ := cmd.Flags().GetString("dept-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--dept-ids 为必填参数") + } + var deptIds []int64 + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return fmt.Errorf("--dept-ids 元素须为整数: %w", err) + } + deptIds = append(deptIds, n) + } + if len(deptIds) == 0 { + return fmt.Errorf("--dept-ids 不能为空列表") + } + input["deptIds"] = deptIds + + // 可选 string + for _, p := range []struct{ flag, key string }{ + {"student-number", "studentNumber"}, + {"email", "email"}, + {"intake", "intake"}, + {"outtake", "outtake"}, + } { + if v, _ := cmd.Flags().GetString(p.flag); strings.TrimSpace(v) != "" { + input[p.key] = strings.TrimSpace(v) + } + } + + return callMCPToolOnServer("college-contact", "update_alumnus", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(updateAlumnusCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "update_alumnus", + CanonicalPath: "college-contact.update_alumnus", + CLIPath: "college-contact alumni update-alumnus", + PrimaryCLIPath: "college-contact alumni update-alumnus", + }, + Description: "更新校友信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "update_alumnus"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "更新校友信息", + UseWhen: []string{"需要更新校友信息时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni update-alumnus --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "dept-ids", Property: "input.deptIds", Required: boolPtr(true)}, + {Name: "student-number", Property: "input.studentNumber", Required: boolPtr(false)}, + {Name: "email", Property: "input.email", Required: boolPtr(false)}, + {Name: "intake", Property: "input.intake", Required: boolPtr(false)}, + {Name: "outtake", Property: "input.outtake", Required: boolPtr(false)}, + }, + }, + }) + + updateAlumnusCmd.Flags().String("staff-id", "", "校友 staffId(必填)") + updateAlumnusCmd.Flags().String("name", "", "校友姓名(必填)") + updateAlumnusCmd.Flags().String("dept-ids", "", "校友部门 ID 列表,逗号分隔(必填)") + updateAlumnusCmd.Flags().String("student-number", "", "学号") + updateAlumnusCmd.Flags().String("email", "", "邮箱") + updateAlumnusCmd.Flags().String("intake", "", "入学年份") + updateAlumnusCmd.Flags().String("outtake", "", "毕业年份") + + // ── remove-alumnus ──────────────────────────────────────── + removeAlumnusCmd := &cobra.Command{ + Use: "remove-alumnus", + Short: "删除校友", + Long: `从校友部门中删除指定校友。注意:删除操作不可逆。 + +必填: + --staff-id 校友 staffId + --alumni-dept-id 校友部门 ID + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact alumni remove-alumnus --staff-id staff001 --alumni-dept-id 12345 --dry-run + dws college-contact alumni remove-alumnus --staff-id staff001 --alumni-dept-id 12345 -y + dws college-contact alumni remove-alumnus --staff-id staff001 --alumni-dept-id 12345 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + staffId, _ := cmd.Flags().GetString("staff-id") + staffId = strings.TrimSpace(staffId) + if staffId == "" { + return fmt.Errorf("--staff-id 为必填参数") + } + + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + + if err := collegeContactConfirmDestructive(cmd, "删除校友"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "delete_alumnus", map[string]any{ + "input": map[string]any{ + "staffId": staffId, + "alumniDeptId": alumniDeptId, + }, + }) + }, + } + + DeclareLeafMetadata(removeAlumnusCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "delete_alumnus", + CanonicalPath: "college-contact.delete_alumnus", + CLIPath: "college-contact alumni remove-alumnus", + PrimaryCLIPath: "college-contact alumni remove-alumnus", + }, + Description: "删除校友", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "delete_alumnus"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "删除校友", + UseWhen: []string{"需要删除校友时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni remove-alumnus --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "staff-id", Property: "input.staffId", Required: boolPtr(true)}, + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + }, + }, + }) + + removeAlumnusCmd.Flags().String("staff-id", "", "校友 staffId(必填)") + removeAlumnusCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + + // ── cancel-invite ───────────────────────────────────────── + cancelAlumniInviteCmd := &cobra.Command{ + Use: "cancel-invite", + Short: "取消校友邀请", + Long: `取消校友邀请记录,返回操作是否成功。注意:取消操作不可逆。 + +必填: + --alumni-dept-id 校友部门 ID + --staff-ids staffId 列表(作为 inviteId 使用),逗号分隔 + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact alumni cancel-invite --alumni-dept-id 12345 --staff-ids staff001,staff002 --dry-run + dws college-contact alumni cancel-invite --alumni-dept-id 12345 --staff-ids staff001 -y + dws college-contact alumni cancel-invite --alumni-dept-id 12345 --staff-ids staff001 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + + raw, _ := cmd.Flags().GetString("staff-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--staff-ids 为必填参数") + } + var staffIds []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIds = append(staffIds, part) + } + } + if len(staffIds) == 0 { + return fmt.Errorf("--staff-ids 不能为空列表") + } + + if err := collegeContactConfirmDestructive(cmd, "取消校友邀请"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "delete_alumni_invite_record", map[string]any{ + "input": map[string]any{ + "alumniDeptId": alumniDeptId, + "staffIds": staffIds, + }, + }) + }, + } + + DeclareLeafMetadata(cancelAlumniInviteCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "delete_alumni_invite_record", + CanonicalPath: "college-contact.delete_alumni_invite_record", + CLIPath: "college-contact alumni cancel-invite", + PrimaryCLIPath: "college-contact alumni cancel-invite", + }, + Description: "取消校友邀请", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "delete_alumni_invite_record"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "取消校友邀请", + UseWhen: []string{"需要取消校友邀请时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni cancel-invite --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(true)}, + }, + }, + }) + + cancelAlumniInviteCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + cancelAlumniInviteCmd.Flags().String("staff-ids", "", "staffId 列表(作为 inviteId 使用),逗号分隔(必填)") + + // ── create-group ────────────────────────────────────────── + createAlumniGroupCmd := &cobra.Command{ + Use: "create-group", + Short: "创建校友群", + Long: `为指定校友部门创建校友群,返回群信息(corpId/deptId/cid/name/owner)。 + +必填: + --alumni-dept-id 校友部门 ID`, + Example: ` dws college-contact alumni create-group --alumni-dept-id 12345 --dry-run + dws college-contact alumni create-group --alumni-dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + + return callMCPToolOnServer("college-contact", "create_alumni_group", map[string]any{ + "input": map[string]any{ + "alumniDeptId": alumniDeptId, + }, + }) + }, + } + + DeclareLeafMetadata(createAlumniGroupCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "create_alumni_group", + CanonicalPath: "college-contact.create_alumni_group", + CLIPath: "college-contact alumni create-group", + PrimaryCLIPath: "college-contact alumni create-group", + }, + Description: "创建校友群", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "create_alumni_group"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建校友群", + UseWhen: []string{"需要创建校友群时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni create-group --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + }, + }, + }) + + createAlumniGroupCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + + // ── disband-group ───────────────────────────────────────── + disbandAlumniGroupCmd := &cobra.Command{ + Use: "disband-group", + Short: "解散校友群", + Long: `解散指定校友部门的校友群。注意:解散操作不可逆。 + +必填: + --alumni-dept-id 校友部门 ID + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact alumni disband-group --alumni-dept-id 12345 --dry-run + dws college-contact alumni disband-group --alumni-dept-id 12345 -y + dws college-contact alumni disband-group --alumni-dept-id 12345 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") + alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) + if alumniDeptIdRaw == "" { + return fmt.Errorf("--alumni-dept-id 为必填参数") + } + alumniDeptId, err := strconv.ParseInt(alumniDeptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) + } + + if err := collegeContactConfirmDestructive(cmd, "解散校友群"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "disband_alumni_group", map[string]any{ + "input": map[string]any{ + "alumniDeptId": alumniDeptId, + }, + }) + }, + } + + DeclareLeafMetadata(disbandAlumniGroupCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "disband_alumni_group", + CanonicalPath: "college-contact.disband_alumni_group", + CLIPath: "college-contact alumni disband-group", + PrimaryCLIPath: "college-contact alumni disband-group", + }, + Description: "解散校友群", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "disband_alumni_group"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "解散校友群", + UseWhen: []string{"需要解散校友群时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni disband-group --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, + }, + }, + }) + + disbandAlumniGroupCmd.Flags().String("alumni-dept-id", "", "校友部门 ID(必填)") + + // ── get-alumni-org-from-graduate ─────────────────────────── + getAlumniOrgFromGraduateCmd := &cobra.Command{ + Use: "get-alumni-org-from-graduate", + Short: "查询毕业生校友组织", + Long: `查询毕业生校友组织信息,无入参,直接返回毕业生校友组织信息。`, + Example: ` dws college-contact alumni get-alumni-org-from-graduate --dry-run + dws college-contact alumni get-alumni-org-from-graduate -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("college-contact", "get_alumni_org_from_graduate", map[string]any{ + "input": map[string]any{}, + }) + }, + } + + DeclareLeafMetadata(getAlumniOrgFromGraduateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_alumni_org_from_graduate", + CanonicalPath: "college-contact.get_alumni_org_from_graduate", + CLIPath: "college-contact alumni get-alumni-org-from-graduate", + PrimaryCLIPath: "college-contact alumni get-alumni-org-from-graduate", + }, + Description: "查询毕业生校友组织", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_alumni_org_from_graduate"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询毕业生校友组织", + UseWhen: []string{"需要查询毕业生校友组织时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni get-alumni-org-from-graduate --help"}, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + // ── create-alumni-org ────────────────────────────────────── + createAlumniOrgCmd := &cobra.Command{ + Use: "create-alumni-org", + Short: "创建校友会组织", + Long: `创建校友会组织,返回新组织信息。 + +必填: + --org-name 校友会组织名称`, + Example: ` dws college-contact alumni create-alumni-org --org-name 某大学校友会 --dry-run + dws college-contact alumni create-alumni-org --org-name 某大学校友会 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + orgName, _ := cmd.Flags().GetString("org-name") + orgName = strings.TrimSpace(orgName) + if orgName == "" { + return fmt.Errorf("--org-name 为必填参数") + } + + return callMCPToolOnServer("college-contact", "create_alumni_org", map[string]any{ + "input": map[string]any{ + "orgName": orgName, + }, + }) + }, + } + + DeclareLeafMetadata(createAlumniOrgCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "create_alumni_org", + CanonicalPath: "college-contact.create_alumni_org", + CLIPath: "college-contact alumni create-alumni-org", + PrimaryCLIPath: "college-contact alumni create-alumni-org", + }, + Description: "创建校友会组织", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "create_alumni_org"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建校友会组织", + UseWhen: []string{"需要创建校友会组织时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni create-alumni-org --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "org-name", Property: "input.orgName", Required: boolPtr(true)}, + }, + }, + }) + + createAlumniOrgCmd.Flags().String("org-name", "", "校友会组织名称(必填)") + + // ── add-alumni-org-main-admins ──────────────────────────── + addAlumniOrgMainAdminsCmd := &cobra.Command{ + Use: "add-alumni-org-main-admins", + Short: "添加校友会组织管理员", + Long: `为校友会组织添加管理员(主管理员)。 + +必填: + --admin-user-ids 管理员 userId 列表,逗号分隔`, + Example: ` dws college-contact alumni add-alumni-org-main-admins --admin-user-ids user001,user002 --dry-run + dws college-contact alumni add-alumni-org-main-admins --admin-user-ids user001 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + raw, _ := cmd.Flags().GetString("admin-user-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--admin-user-ids 为必填参数") + } + var adminUserIds []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + adminUserIds = append(adminUserIds, part) + } + } + if len(adminUserIds) == 0 { + return fmt.Errorf("--admin-user-ids 不能为空列表") + } + + return callMCPToolOnServer("college-contact", "add_alumni_org_main_admins", map[string]any{ + "input": map[string]any{ + "adminUserIds": adminUserIds, + }, + }) + }, + } + + DeclareLeafMetadata(addAlumniOrgMainAdminsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "add_alumni_org_main_admins", + CanonicalPath: "college-contact.add_alumni_org_main_admins", + CLIPath: "college-contact alumni add-alumni-org-main-admins", + PrimaryCLIPath: "college-contact alumni add-alumni-org-main-admins", + }, + Description: "添加校友会组织管理员", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "add_alumni_org_main_admins"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "添加校友会组织管理员", + UseWhen: []string{"需要添加校友会组织管理员时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact alumni add-alumni-org-main-admins --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "admin-user-ids", Property: "input.adminUserIds", Required: boolPtr(true)}, + }, + }, + }) + + addAlumniOrgMainAdminsCmd.Flags().String("admin-user-ids", "", "管理员 userId 列表,逗号分隔(必填)") + + // ════════════════════════════════════════════════════════════ + // graduate 子命令组 — 毕业年级管理 + // ════════════════════════════════════════════════════════════ + + graduateCmd := &cobra.Command{Use: "graduate", Short: "毕业年级管理", RunE: groupRunE} + + // ── query-graduate-years ────────────────────────────────── + queryGraduateYearsCmd := &cobra.Command{ + Use: "query-graduate-years", + Short: "查询毕业年级列表", + Long: `查询毕业年级列表,无入参,直接返回所有毕业年级。`, + Example: ` dws college-contact graduate query-graduate-years --dry-run + dws college-contact graduate query-graduate-years -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("college-contact", "query_graduate_years", map[string]any{ + "input": map[string]any{}, + }) + }, + } + + DeclareLeafMetadata(queryGraduateYearsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "query_graduate_years", + CanonicalPath: "college-contact.query_graduate_years", + CLIPath: "college-contact graduate query-graduate-years", + PrimaryCLIPath: "college-contact graduate query-graduate-years", + }, + Description: "查询毕业年级列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "query_graduate_years"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询毕业年级列表", + UseWhen: []string{"需要查询毕业年级列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate query-graduate-years --help"}, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + // ── query-graduate-depts ────────────────────────────────── + queryGraduateDeptsCmd := &cobra.Command{ + Use: "query-graduate-depts", + Short: "查询待毕业部门列表", + Long: `查询指定部门下的待毕业部门列表。 + +必填: + --dept-id 部门 ID + +可选: + --graduate-year 毕业年份`, + Example: ` dws college-contact graduate query-graduate-depts --dept-id 12345 --dry-run + dws college-contact graduate query-graduate-depts --dept-id 12345 --graduate-year 2026 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIdRaw, _ := cmd.Flags().GetString("dept-id") + deptIdRaw = strings.TrimSpace(deptIdRaw) + if deptIdRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptId, err := strconv.ParseInt(deptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + + input := map[string]any{"deptId": deptId} + if v, _ := cmd.Flags().GetString("graduate-year"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--graduate-year 须为整数: %w", err) + } + input["graduateYear"] = n + } + + return callMCPToolOnServer("college-contact", "query_graduate_depts", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(queryGraduateDeptsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "query_graduate_depts", + CanonicalPath: "college-contact.query_graduate_depts", + CLIPath: "college-contact graduate query-graduate-depts", + PrimaryCLIPath: "college-contact graduate query-graduate-depts", + }, + Description: "查询待毕业部门列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "query_graduate_depts"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询待毕业部门列表", + UseWhen: []string{"需要查询待毕业部门列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate query-graduate-depts --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "graduate-year", Property: "input.graduateYear", Required: boolPtr(false)}, + }, + }, + }) + + queryGraduateDeptsCmd.Flags().String("dept-id", "", "部门 ID(必填)") + queryGraduateDeptsCmd.Flags().String("graduate-year", "", "毕业年份") + + // ── query-graduate-sub-depts ────────────────────────────── + queryGraduateSubDeptsCmd := &cobra.Command{ + Use: "query-graduate-sub-depts", + Short: "查询毕业子部门列表", + Long: `查询指定部门的毕业子部门列表。 + +必填: + --dept-id 部门 ID`, + Example: ` dws college-contact graduate query-graduate-sub-depts --dept-id 12345 --dry-run + dws college-contact graduate query-graduate-sub-depts --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIdRaw, _ := cmd.Flags().GetString("dept-id") + deptIdRaw = strings.TrimSpace(deptIdRaw) + if deptIdRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptId, err := strconv.ParseInt(deptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + + return callMCPToolOnServer("college-contact", "query_graduate_sub_depts", map[string]any{ + "input": map[string]any{ + "deptId": deptId, + }, + }) + }, + } + + DeclareLeafMetadata(queryGraduateSubDeptsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "query_graduate_sub_depts", + CanonicalPath: "college-contact.query_graduate_sub_depts", + CLIPath: "college-contact graduate query-graduate-sub-depts", + PrimaryCLIPath: "college-contact graduate query-graduate-sub-depts", + }, + Description: "查询毕业子部门列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "query_graduate_sub_depts"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询毕业子部门列表", + UseWhen: []string{"需要查询毕业子部门列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate query-graduate-sub-depts --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + queryGraduateSubDeptsCmd.Flags().String("dept-id", "", "部门 ID(必填)") + + // ── query-page-graduate-users ───────────────────────────── + queryPageGraduateUsersCmd := &cobra.Command{ + Use: "query-page-graduate-users", + Short: "分页查询待毕业学生列表", + Long: `分页查询指定部门下的待毕业学生列表。 + +必填: + --dept-id 部门 ID + +可选: + --graduate-year 毕业年份 + --offset 分页偏移量 + --size 分页大小`, + Example: ` dws college-contact graduate query-page-graduate-users --dept-id 12345 --dry-run + dws college-contact graduate query-page-graduate-users --dept-id 12345 --graduate-year 2026 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIdRaw, _ := cmd.Flags().GetString("dept-id") + deptIdRaw = strings.TrimSpace(deptIdRaw) + if deptIdRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptId, err := strconv.ParseInt(deptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + + input := map[string]any{"deptId": deptId} + for _, opt := range []struct { + flag string + key string + }{ + {"graduate-year", "graduateYear"}, + {"offset", "offset"}, + {"size", "size"}, + } { + if v, _ := cmd.Flags().GetString(opt.flag); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", opt.flag, err) + } + input[opt.key] = n + } + } + + return callMCPToolOnServer("college-contact", "query_page_graduate_users", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(queryPageGraduateUsersCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "query_page_graduate_users", + CanonicalPath: "college-contact.query_page_graduate_users", + CLIPath: "college-contact graduate query-page-graduate-users", + PrimaryCLIPath: "college-contact graduate query-page-graduate-users", + }, + Description: "分页查询待毕业学生列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "query_page_graduate_users"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "分页查询待毕业学生列表", + UseWhen: []string{"需要分页查询待毕业学生列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate query-page-graduate-users --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "graduate-year", Property: "input.graduateYear", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + queryPageGraduateUsersCmd.Flags().String("dept-id", "", "部门 ID(必填)") + queryPageGraduateUsersCmd.Flags().String("graduate-year", "", "毕业年份") + queryPageGraduateUsersCmd.Flags().String("offset", "", "分页偏移量") + queryPageGraduateUsersCmd.Flags().String("size", "", "分页大小") + + // ── get-task-result ─────────────────────────────────────── + getTaskResultCmd := &cobra.Command{ + Use: "get-task-result", + Short: "查询异步任务执行结果", + Long: `查询异步任务的执行结果(如毕业、恢复等异步操作)。 + +必填: + --request-no 异步任务请求号 + +可选: + --type 异步任务类型(RESTORE / ADD_GRADUATE / GRADUATE)`, + Example: ` dws college-contact graduate get-task-result --request-no xxx123 --dry-run + dws college-contact graduate get-task-result --request-no xxx123 --type GRADUATE -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + requestNo, _ := cmd.Flags().GetString("request-no") + requestNo = strings.TrimSpace(requestNo) + if requestNo == "" { + return fmt.Errorf("--request-no 为必填参数") + } + + input := map[string]any{"requestNo": requestNo} + if v, _ := cmd.Flags().GetString("type"); strings.TrimSpace(v) != "" { + input["type"] = strings.TrimSpace(v) + } + + return callMCPToolOnServer("college-contact", "get_task_result", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(getTaskResultCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_task_result", + CanonicalPath: "college-contact.get_task_result", + CLIPath: "college-contact graduate get-task-result", + PrimaryCLIPath: "college-contact graduate get-task-result", + }, + Description: "查询异步任务执行结果", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_task_result"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询异步任务执行结果", + UseWhen: []string{"需要查询异步任务执行结果时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate get-task-result --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "request-no", Property: "input.requestNo", Required: boolPtr(true)}, + {Name: "type", Property: "input.type", Required: boolPtr(false)}, + }, + }, + }) + + getTaskResultCmd.Flags().String("request-no", "", "异步任务请求号(必填)") + getTaskResultCmd.Flags().String("type", "", "异步任务类型:RESTORE / ADD_GRADUATE / GRADUATE") + + // ── get-alumni-org ──────────────────────────────────────── + getAlumniOrgCmd := &cobra.Command{ + Use: "get-alumni-org", + Short: "查询校友组织信息", + Long: `查询校友组织信息(组织 ID、名称、主管理员等),无入参,直接返回组织信息。`, + Example: ` dws college-contact graduate get-alumni-org --dry-run + dws college-contact graduate get-alumni-org -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("college-contact", "get_alumni_org", map[string]any{ + "input": map[string]any{}, + }) + }, + } + + DeclareLeafMetadata(getAlumniOrgCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_alumni_org", + CanonicalPath: "college-contact.get_alumni_org", + CLIPath: "college-contact graduate get-alumni-org", + PrimaryCLIPath: "college-contact graduate get-alumni-org", + }, + Description: "查询校友组织信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_alumni_org"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询校友组织信息", + UseWhen: []string{"需要查询校友组织信息时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate get-alumni-org --help"}, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + // ── query-restore-sub-depts ────────────────────────────── + queryRestoreSubDeptsCmd := &cobra.Command{ + Use: "query-restore-sub-depts", + Short: "查询可恢复子部门列表", + Long: `查询指定部门下的可恢复子部门列表。 + +必填: + --dept-id 部门 ID`, + Example: ` dws college-contact graduate query-restore-sub-depts --dept-id 12345 --dry-run + dws college-contact graduate query-restore-sub-depts --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIdRaw, _ := cmd.Flags().GetString("dept-id") + deptIdRaw = strings.TrimSpace(deptIdRaw) + if deptIdRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptId, err := strconv.ParseInt(deptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + + return callMCPToolOnServer("college-contact", "query_restore_sub_depts", map[string]any{ + "input": map[string]any{ + "deptId": deptId, + }, + }) + }, + } + + DeclareLeafMetadata(queryRestoreSubDeptsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "query_restore_sub_depts", + CanonicalPath: "college-contact.query_restore_sub_depts", + CLIPath: "college-contact graduate query-restore-sub-depts", + PrimaryCLIPath: "college-contact graduate query-restore-sub-depts", + }, + Description: "查询可恢复子部门列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "query_restore_sub_depts"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询可恢复子部门列表", + UseWhen: []string{"需要查询可恢复子部门列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate query-restore-sub-depts --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + queryRestoreSubDeptsCmd.Flags().String("dept-id", "", "部门 ID(必填)") + + // ── query-dept-deleted-emps ────────────────────────────── + queryDeptDeletedEmpsCmd := &cobra.Command{ + Use: "query-dept-deleted-emps", + Short: "查询部门可恢复员工列表", + Long: `分页查询指定部门下的可恢复员工列表。 + +必填: + --dept-id 部门 ID + +可选: + --offset 分页偏移量 + --size 分页大小`, + Example: ` dws college-contact graduate query-dept-deleted-emps --dept-id 12345 --dry-run + dws college-contact graduate query-dept-deleted-emps --dept-id 12345 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIdRaw, _ := cmd.Flags().GetString("dept-id") + deptIdRaw = strings.TrimSpace(deptIdRaw) + if deptIdRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptId, err := strconv.ParseInt(deptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + + input := map[string]any{"deptId": deptId} + for _, opt := range []struct { + flag string + key string + }{ + {"offset", "offset"}, + {"size", "size"}, + } { + if v, _ := cmd.Flags().GetString(opt.flag); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", opt.flag, err) + } + input[opt.key] = n + } + } + + return callMCPToolOnServer("college-contact", "query_dept_deleted_emps", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(queryDeptDeletedEmpsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "query_dept_deleted_emps", + CanonicalPath: "college-contact.query_dept_deleted_emps", + CLIPath: "college-contact graduate query-dept-deleted-emps", + PrimaryCLIPath: "college-contact graduate query-dept-deleted-emps", + }, + Description: "查询部门可恢复员工列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "query_dept_deleted_emps"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询部门可恢复员工列表", + UseWhen: []string{"需要查询部门可恢复员工列表时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate query-dept-deleted-emps --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + queryDeptDeletedEmpsCmd.Flags().String("dept-id", "", "部门 ID(必填)") + queryDeptDeletedEmpsCmd.Flags().String("offset", "", "分页偏移量") + queryDeptDeletedEmpsCmd.Flags().String("size", "", "分页大小") + + // ── search-graduate ─────────────────────────────────────── + searchGraduateCmd := &cobra.Command{ + Use: "search-graduate", + Short: "搜索毕业部门与员工", + Long: `按关键词搜索毕业相关的部门与员工。 + +必填: + --keyword 搜索关键词 + +可选: + --offset 分页偏移量 + --size 分页大小`, + Example: ` dws college-contact graduate search-graduate --keyword 计算机 --dry-run + dws college-contact graduate search-graduate --keyword 计算机 --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + keyword, _ := cmd.Flags().GetString("keyword") + keyword = strings.TrimSpace(keyword) + if keyword == "" { + return fmt.Errorf("--keyword 为必填参数") + } + + input := map[string]any{"keyword": keyword} + for _, opt := range []struct { + flag string + key string + }{ + {"offset", "offset"}, + {"size", "size"}, + } { + if v, _ := cmd.Flags().GetString(opt.flag); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--%s 须为整数: %w", opt.flag, err) + } + input[opt.key] = n + } + } + + return callMCPToolOnServer("college-contact", "search_graduate", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(searchGraduateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "search_graduate", + CanonicalPath: "college-contact.search_graduate", + CLIPath: "college-contact graduate search-graduate", + PrimaryCLIPath: "college-contact graduate search-graduate", + }, + Description: "搜索毕业部门与员工", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "search_graduate"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "搜索毕业部门与员工", + UseWhen: []string{"需要搜索毕业部门与员工时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate search-graduate --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "keyword", Property: "input.keyword", Required: boolPtr(true)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + searchGraduateCmd.Flags().String("keyword", "", "搜索关键词(必填)") + searchGraduateCmd.Flags().String("offset", "", "分页偏移量") + searchGraduateCmd.Flags().String("size", "", "分页大小") + + // ── commit-graduate ─────────────────────────────────────── + commitGraduateCmd := &cobra.Command{ + Use: "commit-graduate", + Short: "提交毕业", + Long: `对指定部门提交毕业操作,发起异步毕业任务,返回异步任务编号。 +注意:提交毕业不可逆。 + +必填: + --graduate-dept-ids 待毕业部门 ID 列表,逗号分隔 + --graduate-year 毕业年份 + +可选: + --request-no 异步任务请求号 + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact graduate commit-graduate --graduate-dept-ids 12345,12346 --graduate-year 2026 --dry-run + dws college-contact graduate commit-graduate --graduate-dept-ids 12345,12346 --graduate-year 2026 -y + dws college-contact graduate commit-graduate --graduate-dept-ids 12345,12346 --graduate-year 2026 --request-no xxx123 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + raw, _ := cmd.Flags().GetString("graduate-dept-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--graduate-dept-ids 为必填参数") + } + var graduateDeptIds []int64 + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return fmt.Errorf("--graduate-dept-ids 须为逗号分隔的整数列表: %w", err) + } + graduateDeptIds = append(graduateDeptIds, n) + } + if len(graduateDeptIds) == 0 { + return fmt.Errorf("--graduate-dept-ids 不能为空列表") + } + + graduateYearRaw, _ := cmd.Flags().GetString("graduate-year") + graduateYearRaw = strings.TrimSpace(graduateYearRaw) + if graduateYearRaw == "" { + return fmt.Errorf("--graduate-year 为必填参数") + } + graduateYear, err := strconv.ParseInt(graduateYearRaw, 10, 64) + if err != nil { + return fmt.Errorf("--graduate-year 须为整数: %w", err) + } + + input := map[string]any{ + "graduateDeptIds": graduateDeptIds, + "graduateYear": graduateYear, + } + if v, _ := cmd.Flags().GetString("request-no"); strings.TrimSpace(v) != "" { + input["requestNo"] = strings.TrimSpace(v) + } + + if err := collegeContactConfirmDestructive(cmd, "提交毕业"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "commit_graduate", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(commitGraduateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "commit_graduate", + CanonicalPath: "college-contact.commit_graduate", + CLIPath: "college-contact graduate commit-graduate", + PrimaryCLIPath: "college-contact graduate commit-graduate", + }, + Description: "提交毕业", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "commit_graduate"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "提交毕业", + UseWhen: []string{"需要提交毕业时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate commit-graduate --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "graduate-dept-ids", Property: "input.graduateDeptIds", Required: boolPtr(true)}, + {Name: "graduate-year", Property: "input.graduateYear", Required: boolPtr(true)}, + {Name: "request-no", Property: "input.requestNo", Required: boolPtr(false)}, + }, + }, + }) + + commitGraduateCmd.Flags().String("graduate-dept-ids", "", "待毕业部门 ID 列表,逗号分隔(必填)") + commitGraduateCmd.Flags().String("graduate-year", "", "毕业年份(必填)") + commitGraduateCmd.Flags().String("request-no", "", "异步任务请求号") + + // ── all-graduate ────────────────────────────────────────── + allGraduateCmd := &cobra.Command{ + Use: "all-graduate", + Short: "全部毕业", + Long: `对指定毕业年份的全部待毕业部门提交毕业操作。 +注意:全部毕业不可逆。 + +必填: + --graduate-year 毕业年份 + +可选: + --request-no 异步任务请求号 + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact graduate all-graduate --graduate-year 2026 --dry-run + dws college-contact graduate all-graduate --graduate-year 2026 -y + dws college-contact graduate all-graduate --graduate-year 2026 --request-no xxx123 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + graduateYearRaw, _ := cmd.Flags().GetString("graduate-year") + graduateYearRaw = strings.TrimSpace(graduateYearRaw) + if graduateYearRaw == "" { + return fmt.Errorf("--graduate-year 为必填参数") + } + graduateYear, err := strconv.ParseInt(graduateYearRaw, 10, 64) + if err != nil { + return fmt.Errorf("--graduate-year 须为整数: %w", err) + } + + input := map[string]any{"graduateYear": graduateYear} + if v, _ := cmd.Flags().GetString("request-no"); strings.TrimSpace(v) != "" { + input["requestNo"] = strings.TrimSpace(v) + } + + if err := collegeContactConfirmDestructive(cmd, "全部毕业"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "all_graduate", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(allGraduateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "all_graduate", + CanonicalPath: "college-contact.all_graduate", + CLIPath: "college-contact graduate all-graduate", + PrimaryCLIPath: "college-contact graduate all-graduate", + }, + Description: "全部毕业", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "all_graduate"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "全部毕业", + UseWhen: []string{"需要全部毕业时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate all-graduate --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "graduate-year", Property: "input.graduateYear", Required: boolPtr(true)}, + {Name: "request-no", Property: "input.requestNo", Required: boolPtr(false)}, + }, + }, + }) + + allGraduateCmd.Flags().String("graduate-year", "", "毕业年份(必填)") + allGraduateCmd.Flags().String("request-no", "", "异步任务请求号") + + // ── batch-graduate ──────────────────────────────────────── + batchGraduateCmd := &cobra.Command{ + Use: "batch-graduate", + Short: "批量毕业", + Long: `对指定部门下的学生批量提交毕业,返回批量毕业结果(成功/失败数量)。 +注意:批量毕业不可逆。 + +必填: + --dept-id 部门 ID + --staff-ids 学生 staffId 列表,逗号分隔 + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact graduate batch-graduate --dept-id 12345 --staff-ids staff001,staff002 --dry-run + dws college-contact graduate batch-graduate --dept-id 12345 --staff-ids staff001,staff002 -y + dws college-contact graduate batch-graduate --dept-id 12345 --staff-ids staff001,staff002 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIdRaw, _ := cmd.Flags().GetString("dept-id") + deptIdRaw = strings.TrimSpace(deptIdRaw) + if deptIdRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptId, err := strconv.ParseInt(deptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + + raw, _ := cmd.Flags().GetString("staff-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--staff-ids 为必填参数") + } + var staffIds []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIds = append(staffIds, part) + } + } + if len(staffIds) == 0 { + return fmt.Errorf("--staff-ids 不能为空列表") + } + + if err := collegeContactConfirmDestructive(cmd, "批量毕业"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "batch_graduate", map[string]any{ + "input": map[string]any{ + "deptId": deptId, + "staffIds": staffIds, + }, + }) + }, + } + + DeclareLeafMetadata(batchGraduateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "batch_graduate", + CanonicalPath: "college-contact.batch_graduate", + CLIPath: "college-contact graduate batch-graduate", + PrimaryCLIPath: "college-contact graduate batch-graduate", + }, + Description: "批量毕业", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "batch_graduate"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "批量毕业", + UseWhen: []string{"需要批量毕业时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate batch-graduate --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(true)}, + }, + }, + }) + + batchGraduateCmd.Flags().String("dept-id", "", "部门 ID(必填)") + batchGraduateCmd.Flags().String("staff-ids", "", "学生 staffId 列表,逗号分隔(必填)") + + // ── delete-and-graduate ─────────────────────────────────── + deleteAndGraduateCmd := &cobra.Command{ + Use: "delete-and-graduate", + Short: "删除并毕业", + Long: `对指定部门下的学生执行删除并毕业操作,返回批量毕业结果(成功/失败数量)。 +注意:删除并毕业不可逆。 + +必填: + --dept-id 部门 ID + --staff-ids 学生 staffId 列表,逗号分隔 + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact graduate delete-and-graduate --dept-id 12345 --staff-ids staff001,staff002 --dry-run + dws college-contact graduate delete-and-graduate --dept-id 12345 --staff-ids staff001,staff002 -y + dws college-contact graduate delete-and-graduate --dept-id 12345 --staff-ids staff001,staff002 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIdRaw, _ := cmd.Flags().GetString("dept-id") + deptIdRaw = strings.TrimSpace(deptIdRaw) + if deptIdRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptId, err := strconv.ParseInt(deptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + + raw, _ := cmd.Flags().GetString("staff-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--staff-ids 为必填参数") + } + var staffIds []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIds = append(staffIds, part) + } + } + if len(staffIds) == 0 { + return fmt.Errorf("--staff-ids 不能为空列表") + } + + if err := collegeContactConfirmDestructive(cmd, "删除并毕业"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "delete_and_graduate", map[string]any{ + "input": map[string]any{ + "deptId": deptId, + "staffIds": staffIds, + }, + }) + }, + } + + DeclareLeafMetadata(deleteAndGraduateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "delete_and_graduate", + CanonicalPath: "college-contact.delete_and_graduate", + CLIPath: "college-contact graduate delete-and-graduate", + PrimaryCLIPath: "college-contact graduate delete-and-graduate", + }, + Description: "删除并毕业", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "delete_and_graduate"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "删除并毕业", + UseWhen: []string{"需要删除并毕业时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate delete-and-graduate --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(true)}, + }, + }, + }) + + deleteAndGraduateCmd.Flags().String("dept-id", "", "部门 ID(必填)") + deleteAndGraduateCmd.Flags().String("staff-ids", "", "学生 staffId 列表,逗号分隔(必填)") + + // ── batch-delete-pending ────────────────────────────────── + batchDeletePendingCmd := &cobra.Command{ + Use: "batch-delete-pending", + Short: "批量删除待毕业学生", + Long: `对指定部门下的待毕业学生执行批量删除,返回操作是否成功。 +注意:批量删除不可逆。 + +必填: + --dept-id 部门 ID + --staff-ids 学生 staffId 列表,逗号分隔 + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact graduate batch-delete-pending --dept-id 12345 --staff-ids staff001,staff002 --dry-run + dws college-contact graduate batch-delete-pending --dept-id 12345 --staff-ids staff001,staff002 -y + dws college-contact graduate batch-delete-pending --dept-id 12345 --staff-ids staff001,staff002 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIdRaw, _ := cmd.Flags().GetString("dept-id") + deptIdRaw = strings.TrimSpace(deptIdRaw) + if deptIdRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptId, err := strconv.ParseInt(deptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + + raw, _ := cmd.Flags().GetString("staff-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--staff-ids 为必填参数") + } + var staffIds []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIds = append(staffIds, part) + } + } + if len(staffIds) == 0 { + return fmt.Errorf("--staff-ids 不能为空列表") + } + + if err := collegeContactConfirmDestructive(cmd, "批量删除待毕业学生"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "batch_delete_pending", map[string]any{ + "input": map[string]any{ + "deptId": deptId, + "staffIds": staffIds, + }, + }) + }, + } + + DeclareLeafMetadata(batchDeletePendingCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "batch_delete_pending", + CanonicalPath: "college-contact.batch_delete_pending", + CLIPath: "college-contact graduate batch-delete-pending", + PrimaryCLIPath: "college-contact graduate batch-delete-pending", + }, + Description: "批量删除待毕业学生", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "batch_delete_pending"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "批量删除待毕业学生", + UseWhen: []string{"需要批量删除待毕业学生时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate batch-delete-pending --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(true)}, + }, + }, + }) + + batchDeletePendingCmd.Flags().String("dept-id", "", "部门 ID(必填)") + batchDeletePendingCmd.Flags().String("staff-ids", "", "学生 staffId 列表,逗号分隔(必填)") + + // ── batch-update-pending ────────────────────────────────── + batchUpdatePendingCmd := &cobra.Command{ + Use: "batch-update-pending", + Short: "批量更新待毕业学生", + Long: `批量更新指定部门下待毕业学生的毕业年份,返回操作是否成功。 +注意:批量更新影响面大,请谨慎执行。 + +必填: + --dept-id 部门 ID + --staff-ids 学生 staffId 列表,逗号分隔 + --graduate-year 毕业年份 + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact graduate batch-update-pending --dept-id 12345 --staff-ids staff001,staff002 --graduate-year 2026 --dry-run + dws college-contact graduate batch-update-pending --dept-id 12345 --staff-ids staff001,staff002 --graduate-year 2026 -y + dws college-contact graduate batch-update-pending --dept-id 12345 --staff-ids staff001,staff002 --graduate-year 2026 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptIdRaw, _ := cmd.Flags().GetString("dept-id") + deptIdRaw = strings.TrimSpace(deptIdRaw) + if deptIdRaw == "" { + return fmt.Errorf("--dept-id 为必填参数") + } + deptId, err := strconv.ParseInt(deptIdRaw, 10, 64) + if err != nil { + return fmt.Errorf("--dept-id 须为整数: %w", err) + } + + raw, _ := cmd.Flags().GetString("staff-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--staff-ids 为必填参数") + } + var staffIds []string + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part != "" { + staffIds = append(staffIds, part) + } + } + if len(staffIds) == 0 { + return fmt.Errorf("--staff-ids 不能为空列表") + } + + graduateYearRaw, _ := cmd.Flags().GetString("graduate-year") + graduateYearRaw = strings.TrimSpace(graduateYearRaw) + if graduateYearRaw == "" { + return fmt.Errorf("--graduate-year 为必填参数") + } + graduateYear, err := strconv.ParseInt(graduateYearRaw, 10, 64) + if err != nil { + return fmt.Errorf("--graduate-year 须为整数: %w", err) + } + + if err := collegeContactConfirmDestructive(cmd, "批量更新待毕业学生"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "batch_update_pending", map[string]any{ + "input": map[string]any{ + "deptId": deptId, + "staffIds": staffIds, + "graduateYear": graduateYear, + }, + }) + }, + } + + DeclareLeafMetadata(batchUpdatePendingCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "batch_update_pending", + CanonicalPath: "college-contact.batch_update_pending", + CLIPath: "college-contact graduate batch-update-pending", + PrimaryCLIPath: "college-contact graduate batch-update-pending", + }, + Description: "批量更新待毕业学生", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "batch_update_pending"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "批量更新待毕业学生", + UseWhen: []string{"需要批量更新待毕业学生时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate batch-update-pending --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(true)}, + {Name: "graduate-year", Property: "input.graduateYear", Required: boolPtr(true)}, + }, + }, + }) + + batchUpdatePendingCmd.Flags().String("dept-id", "", "部门 ID(必填)") + batchUpdatePendingCmd.Flags().String("staff-ids", "", "学生 staffId 列表,逗号分隔(必填)") + batchUpdatePendingCmd.Flags().String("graduate-year", "", "毕业年份(必填)") + + // ── commit-restore ─────────────────────────────────────── + commitRestoreCmd := &cobra.Command{ + Use: "commit-restore", + Short: "提交恢复", + Long: `对指定部门提交恢复操作,发起异步恢复任务,返回异步任务编号。 +注意:提交恢复不可逆。 + +必填: + --graduate-dept-ids 待毕业部门 ID 列表,逗号分隔 + +可选: + --request-no 异步任务请求号 + +非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact graduate commit-restore --graduate-dept-ids 12345,12346 --dry-run + dws college-contact graduate commit-restore --graduate-dept-ids 12345,12346 -y + dws college-contact graduate commit-restore --graduate-dept-ids 12345,12346 --request-no xxx123 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + raw, _ := cmd.Flags().GetString("graduate-dept-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--graduate-dept-ids 为必填参数") + } + var graduateDeptIds []int64 + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + n, err := strconv.ParseInt(part, 10, 64) + if err != nil { + return fmt.Errorf("--graduate-dept-ids 须为逗号分隔的整数列表: %w", err) + } + graduateDeptIds = append(graduateDeptIds, n) + } + if len(graduateDeptIds) == 0 { + return fmt.Errorf("--graduate-dept-ids 不能为空列表") + } + + input := map[string]any{"graduateDeptIds": graduateDeptIds} + if v, _ := cmd.Flags().GetString("request-no"); strings.TrimSpace(v) != "" { + input["requestNo"] = strings.TrimSpace(v) + } + + if err := collegeContactConfirmDestructive(cmd, "提交恢复"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "commit_restore", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(commitRestoreCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "commit_restore", + CanonicalPath: "college-contact.commit_restore", + CLIPath: "college-contact graduate commit-restore", + PrimaryCLIPath: "college-contact graduate commit-restore", + }, + Description: "提交恢复", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "commit_restore"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "提交恢复", + UseWhen: []string{"需要提交恢复时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact graduate commit-restore --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "graduate-dept-ids", Property: "input.graduateDeptIds", Required: boolPtr(true)}, + {Name: "request-no", Property: "input.requestNo", Required: boolPtr(false)}, + }, + }, + }) + + commitRestoreCmd.Flags().String("graduate-dept-ids", "", "待毕业部门 ID 列表,逗号分隔(必填)") + commitRestoreCmd.Flags().String("request-no", "", "异步任务请求号") + + // ── group 子命令组 ──────────────────────────────────────── + groupCmd := &cobra.Command{ + Use: "group", + Short: "规则管理", + RunE: groupRunE, + } + + // ── query-group-rule ───────────────────────────────────── + queryGroupRuleCmd := &cobra.Command{ + Use: "query-group-rule", + Short: "查询规则", + Long: `分页查询规则列表,支持按规则名称过滤。 + +可选: + --name 规则名称 + --offset 分页偏移量 + --size 分页大小`, + Example: ` dws college-contact group query-group-rule + dws college-contact group query-group-rule --name 分组规则 + dws college-contact group query-group-rule --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + if v, _ := cmd.Flags().GetString("name"); strings.TrimSpace(v) != "" { + input["name"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("offset"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return fmt.Errorf("--offset 须为整数: %w", err) + } + input["offset"] = n + } + if v, _ := cmd.Flags().GetString("size"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return fmt.Errorf("--size 须为整数: %w", err) + } + input["size"] = n + } + + return callMCPToolOnServer("college-contact", "query_group_rule", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(queryGroupRuleCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "query_group_rule", + CanonicalPath: "college-contact.query_group_rule", + CLIPath: "college-contact group query-group-rule", + PrimaryCLIPath: "college-contact group query-group-rule", + }, + Description: "查询规则", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "query_group_rule"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询规则", + UseWhen: []string{"需要查询规则时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact group query-group-rule --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "name", Property: "input.name", Required: boolPtr(false)}, + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + queryGroupRuleCmd.Flags().String("name", "", "规则名称") + queryGroupRuleCmd.Flags().String("offset", "", "分页偏移量") + queryGroupRuleCmd.Flags().String("size", "", "分页大小") + + // ── get-group-rule-schedule ────────────────────────────── + getGroupRuleScheduleCmd := &cobra.Command{ + Use: "get-group-rule-schedule", + Short: "查询规则调度", + Long: `查询规则调度 cron 表达式,无入参。`, + Example: ` dws college-contact group get-group-rule-schedule + dws college-contact group get-group-rule-schedule -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("college-contact", "get_group_rule_schedule", map[string]any{ + "input": map[string]any{}, + }) + }, + } + + DeclareLeafMetadata(getGroupRuleScheduleCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "get_group_rule_schedule", + CanonicalPath: "college-contact.get_group_rule_schedule", + CLIPath: "college-contact group get-group-rule-schedule", + PrimaryCLIPath: "college-contact group get-group-rule-schedule", + }, + Description: "查询规则调度", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "get_group_rule_schedule"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询规则调度", + UseWhen: []string{"需要查询规则调度时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact group get-group-rule-schedule --help"}, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + // ── query-preview-data ─────────────────────────────────── + queryPreviewDataCmd := &cobra.Command{ + Use: "query-preview-data", + Short: "查询规则预览数据", + Long: `分页查询规则预览数据。 + +可选: + --offset 分页偏移量 + --size 分页大小`, + Example: ` dws college-contact group query-preview-data + dws college-contact group query-preview-data --offset 0 --size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + if v, _ := cmd.Flags().GetString("offset"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return fmt.Errorf("--offset 须为整数: %w", err) + } + input["offset"] = n + } + if v, _ := cmd.Flags().GetString("size"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return fmt.Errorf("--size 须为整数: %w", err) + } + input["size"] = n + } + + return callMCPToolOnServer("college-contact", "query_preview_data", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(queryPreviewDataCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "query_preview_data", + CanonicalPath: "college-contact.query_preview_data", + CLIPath: "college-contact group query-preview-data", + PrimaryCLIPath: "college-contact group query-preview-data", + }, + Description: "查询规则预览数据", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "query_preview_data"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询规则预览数据", + UseWhen: []string{"需要查询规则预览数据时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact group query-preview-data --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, + {Name: "size", Property: "input.size", Required: boolPtr(false)}, + }, + }, + }) + + queryPreviewDataCmd.Flags().String("offset", "", "分页偏移量") + queryPreviewDataCmd.Flags().String("size", "", "分页大小") + + // ── create-group-rule ──────────────────────────────────── + createGroupRuleCmd := &cobra.Command{ + Use: "create-group-rule", + Short: "创建规则", + Long: `创建分组规则,返回操作是否成功。 + +必填: + --name 规则名称 + --tag-code 标签编码 + --dept-type 部门类型 + +可选: + --auto-admin 是否自动设置管理员(true/false)`, + Example: ` dws college-contact group create-group-rule --name 毕业生分组 --tag-code T001 --dept-type college + dws college-contact group create-group-rule --name 毕业生分组 --tag-code T001 --dept-type college --auto-admin true -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + + // 必填 string + for _, pair := range []struct { + flag string + key string + }{ + {"name", "name"}, + {"tag-code", "tagCode"}, + {"dept-type", "deptType"}, + } { + v, _ := cmd.Flags().GetString(pair.flag) + v = strings.TrimSpace(v) + if v == "" { + return fmt.Errorf("--%s 为必填参数", pair.flag) + } + input[pair.key] = v + } + + // 可选 bool(使用 String flag 以避免 pflag 对 "--flag false" 的错误解析) + if cmd.Flags().Changed("auto-admin") { + aaRaw, _ := cmd.Flags().GetString("auto-admin") + autoAdmin, err := strconv.ParseBool(strings.TrimSpace(aaRaw)) + if err != nil { + return fmt.Errorf("--auto-admin 须为 true 或 false: %w", err) + } + input["autoAdmin"] = autoAdmin + } + + return callMCPToolOnServer("college-contact", "create_group_rule", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(createGroupRuleCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "create_group_rule", + CanonicalPath: "college-contact.create_group_rule", + CLIPath: "college-contact group create-group-rule", + PrimaryCLIPath: "college-contact group create-group-rule", + }, + Description: "创建规则", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "create_group_rule"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建规则", + UseWhen: []string{"需要创建规则时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact group create-group-rule --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "name", Property: "input.name", Required: boolPtr(true)}, + {Name: "tag-code", Property: "input.tagCode", Required: boolPtr(true)}, + {Name: "dept-type", Property: "input.deptType", Required: boolPtr(true)}, + {Name: "auto-admin", Property: "input.autoAdmin", Required: boolPtr(false)}, + }, + }, + }) + + createGroupRuleCmd.Flags().String("name", "", "规则名称(必填)") + createGroupRuleCmd.Flags().String("tag-code", "", "标签编码(必填)") + createGroupRuleCmd.Flags().String("dept-type", "", "部门类型(必填)") + createGroupRuleCmd.Flags().String("auto-admin", "", "是否自动设置管理员(true/false)") + + // ── delete-group-rule ──────────────────────────────────── + deleteGroupRuleCmd := &cobra.Command{ + Use: "delete-group-rule", + Short: "删除规则", + Long: `删除分组规则,返回操作是否成功。 +--rule-id 为必填。 + +注意:删除操作不可逆。非 --dry-run 预览时必须显式传入 --yes 才会真实执行, +未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact group delete-group-rule --rule-id 1 --dry-run + dws college-contact group delete-group-rule --rule-id 1 -y + dws college-contact group delete-group-rule --rule-id 1 -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + ruleIDRaw, _ := cmd.Flags().GetString("rule-id") + ruleIDRaw = strings.TrimSpace(ruleIDRaw) + if ruleIDRaw == "" { + return fmt.Errorf("--rule-id 为必填参数") + } + ruleID, err := strconv.ParseInt(ruleIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--rule-id 须为整数: %w", err) + } + + if err := collegeContactConfirmDestructive(cmd, "删除规则"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "delete_group_rule", map[string]any{ + "input": map[string]any{"ruleId": ruleID}, + }) + }, + } + + DeclareLeafMetadata(deleteGroupRuleCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "delete_group_rule", + CanonicalPath: "college-contact.delete_group_rule", + CLIPath: "college-contact group delete-group-rule", + PrimaryCLIPath: "college-contact group delete-group-rule", + }, + Description: "删除规则", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "delete_group_rule"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "删除规则", + UseWhen: []string{"需要删除规则时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact group delete-group-rule --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "rule-id", Property: "input.ruleId", Required: boolPtr(true)}, + }, + }, + }) + + deleteGroupRuleCmd.Flags().String("rule-id", "", "规则 ID(必填)") + + // ── enable-group-rule ──────────────────────────────────── + enableGroupRuleCmd := &cobra.Command{ + Use: "enable-group-rule", + Short: "启用规则", + Long: `启用分组规则,返回操作是否成功。 +--rule-id 为必填。`, + Example: ` dws college-contact group enable-group-rule --rule-id 1 + dws college-contact group enable-group-rule --rule-id 1 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + ruleIDRaw, _ := cmd.Flags().GetString("rule-id") + ruleIDRaw = strings.TrimSpace(ruleIDRaw) + if ruleIDRaw == "" { + return fmt.Errorf("--rule-id 为必填参数") + } + ruleID, err := strconv.ParseInt(ruleIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--rule-id 须为整数: %w", err) + } + + return callMCPToolOnServer("college-contact", "enable_group_rule", map[string]any{ + "input": map[string]any{"ruleId": ruleID}, + }) + }, + } + + DeclareLeafMetadata(enableGroupRuleCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "enable_group_rule", + CanonicalPath: "college-contact.enable_group_rule", + CLIPath: "college-contact group enable-group-rule", + PrimaryCLIPath: "college-contact group enable-group-rule", + }, + Description: "启用规则", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "enable_group_rule"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "启用规则", + UseWhen: []string{"需要启用规则时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact group enable-group-rule --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "rule-id", Property: "input.ruleId", Required: boolPtr(true)}, + }, + }, + }) + + enableGroupRuleCmd.Flags().String("rule-id", "", "规则 ID(必填)") + + // ── disable-group-rule ─────────────────────────────────── + disableGroupRuleCmd := &cobra.Command{ + Use: "disable-group-rule", + Short: "停用规则", + Long: `停用分组规则,返回操作是否成功。 +--rule-id 为必填。`, + Example: ` dws college-contact group disable-group-rule --rule-id 1 + dws college-contact group disable-group-rule --rule-id 1 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + ruleIDRaw, _ := cmd.Flags().GetString("rule-id") + ruleIDRaw = strings.TrimSpace(ruleIDRaw) + if ruleIDRaw == "" { + return fmt.Errorf("--rule-id 为必填参数") + } + ruleID, err := strconv.ParseInt(ruleIDRaw, 10, 64) + if err != nil { + return fmt.Errorf("--rule-id 须为整数: %w", err) + } + + return callMCPToolOnServer("college-contact", "disable_group_rule", map[string]any{ + "input": map[string]any{"ruleId": ruleID}, + }) + }, + } + + DeclareLeafMetadata(disableGroupRuleCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "disable_group_rule", + CanonicalPath: "college-contact.disable_group_rule", + CLIPath: "college-contact group disable-group-rule", + PrimaryCLIPath: "college-contact group disable-group-rule", + }, + Description: "停用规则", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "disable_group_rule"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "停用规则", + UseWhen: []string{"需要停用规则时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact group disable-group-rule --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "rule-id", Property: "input.ruleId", Required: boolPtr(true)}, + }, + }, + }) + + disableGroupRuleCmd.Flags().String("rule-id", "", "规则 ID(必填)") + + // ── set-group-rule-schedule ────────────────────────────── + setGroupRuleScheduleCmd := &cobra.Command{ + Use: "set-group-rule-schedule", + Short: "设置规则调度", + Long: `设置规则调度 cron 表达式,返回操作是否成功。 + +可选: + --cron cron 表达式`, + Example: ` dws college-contact group set-group-rule-schedule --cron "0 0 2 * * ?" + dws college-contact group set-group-rule-schedule --cron "0 0 2 * * ?" -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + if v, _ := cmd.Flags().GetString("cron"); strings.TrimSpace(v) != "" { + input["cron"] = strings.TrimSpace(v) + } + + return callMCPToolOnServer("college-contact", "set_group_rule_schedule", map[string]any{ + "input": input, + }) + }, + } + + DeclareLeafMetadata(setGroupRuleScheduleCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "set_group_rule_schedule", + CanonicalPath: "college-contact.set_group_rule_schedule", + CLIPath: "college-contact group set-group-rule-schedule", + PrimaryCLIPath: "college-contact group set-group-rule-schedule", + }, + Description: "设置规则调度", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "set_group_rule_schedule"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "设置规则调度", + UseWhen: []string{"需要设置规则调度时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact group set-group-rule-schedule --help"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "cron", Property: "input.cron", Required: boolPtr(false)}, + }, + }, + }) + + setGroupRuleScheduleCmd.Flags().String("cron", "", "cron 表达式") + + // ── execute-group-rule ─────────────────────────────────── + executeGroupRuleCmd := &cobra.Command{ + Use: "execute-group-rule", + Short: "立即执行规则", + Long: `立即执行分组规则(按规则对成员进行分组、建群等批量变更),返回操作是否成功。 +无入参。 + +注意:执行会对成员产生批量变更,影响范围大。非 --dry-run 预览时必须显式传入 +--yes 才会真实执行,未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, + Example: ` dws college-contact group execute-group-rule --dry-run + dws college-contact group execute-group-rule -y + dws college-contact group execute-group-rule -y -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := collegeContactConfirmDestructive(cmd, "立即执行规则"); err != nil { + return err + } + + return callMCPToolOnServer("college-contact", "execute_group_rule", map[string]any{ + "input": map[string]any{}, + }) + }, + } + + DeclareLeafMetadata(executeGroupRuleCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "college-contact", + Name: "execute_group_rule", + CanonicalPath: "college-contact.execute_group_rule", + CLIPath: "college-contact group execute-group-rule", + PrimaryCLIPath: "college-contact group execute-group-rule", + }, + Description: "立即执行规则", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "college-contact", RPCName: "execute_group_rule"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "立即执行规则", + UseWhen: []string{"需要立即执行规则时"}, + AvoidWhen: []string{"与高校通讯录无关的操作"}, + Examples: []string{"dws college-contact group execute-group-rule --help"}, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + groupCmd.AddCommand(queryGroupRuleCmd, getGroupRuleScheduleCmd, queryPreviewDataCmd, createGroupRuleCmd, deleteGroupRuleCmd, enableGroupRuleCmd, disableGroupRuleCmd, setGroupRuleScheduleCmd, executeGroupRuleCmd) + + alumniCmd.AddCommand(getAlumniDeptTreeCmd, getAlumniDeptInfoCmd, listAlumniCmd, queryAlumnusCmd, searchAlumniCmd, listUnacceptedAlumnusCmd, getAlumniGroupCmd, createAlumniDeptsCmd, updateAlumniDeptCmd, deleteAlumniDeptCmd, updateAlumniDeptManagersCmd, addAlumnusCmd, updateAlumnusCmd, removeAlumnusCmd, cancelAlumniInviteCmd, createAlumniGroupCmd, disbandAlumniGroupCmd, getAlumniOrgFromGraduateCmd, createAlumniOrgCmd, addAlumniOrgMainAdminsCmd) + graduateCmd.AddCommand(queryGraduateYearsCmd, queryGraduateDeptsCmd, queryGraduateSubDeptsCmd, queryPageGraduateUsersCmd, getTaskResultCmd, getAlumniOrgCmd, queryRestoreSubDeptsCmd, queryDeptDeletedEmpsCmd, searchGraduateCmd, commitGraduateCmd, allGraduateCmd, batchGraduateCmd, deleteAndGraduateCmd, batchDeletePendingCmd, batchUpdatePendingCmd, commitRestoreCmd) + + employeeCmd.AddCommand(getEmployeeDetailCmd, addEmployeeCmd, removeEmployeeCmd, changeEmployeeTypeCmd, changeEmployeeDeptCmd, sendActiveSmsCmd, listEmployeesCmd, listUnacceptedEmployeesCmd, listUnactiveEmployeesCmd, upgradeStatusCmd, startUpgradeCmd) + root.AddCommand(deptCmd, employeeCmd, alumniCmd, graduateCmd, groupCmd) + + return root +} + +// collegeContactConfirmDestructive 是不可逆操作(删除部门/移除员工)的安全门禁: +// - 全局 --dry-run 仅预览不执行,直接放行; +// - 显式传入全局 --yes/-y 视为已确认,放行; +// - 其余情况拒绝执行,避免误操作产生不可逆变更。 +// +// 全局 flag 由核心框架注册在 rootCmd 上,cobra 执行时会将 persistent flag +// 合并进叶子命令的 Flags();若 flag 不存在(如单测环境),GetBool 返回零值, +// 默认拒绝执行,保持安全兼底。 +func collegeContactConfirmDestructive(cmd *cobra.Command, action string) error { + if deps != nil && deps.Caller != nil && deps.Caller.DryRun() { + return nil + } + if yes, _ := cmd.Flags().GetBool("yes"); yes { + return nil + } + return fmt.Errorf("%s为不可逆操作:请先向用户展示操作摘要并获得明确同意,确认后追加 --yes 执行,或使用 --dry-run 预览", action) +} diff --git a/internal/helpers/college_contact_test.go b/internal/helpers/college_contact_test.go new file mode 100644 index 00000000..95e0a26f --- /dev/null +++ b/internal/helpers/college_contact_test.go @@ -0,0 +1,680 @@ +package helpers + +import ( + "io" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestCollegeContactCommand_Structure(t *testing.T) { + cmd := newCollegeContactCommand() + + if cmd.Name() != "college-contact" { + t.Errorf("expected name 'college-contact', got %q", cmd.Name()) + } + if !cmd.Hidden { + t.Error("extension root command should be Hidden") + } + + // 分组 → 叶子命令映射 + groups := map[string][]string{ + "dept": { + "get-standard-structure", "get-detail", "get-chain", "search", + "create", "update", "delete", "batch-update-type", "overview", + }, + "employee": { + "get-detail", "add", "remove", "change-type", "change-dept", + "send-active-sms", "list-employees", "list-unaccepted", + "list-unactive", "upgrade-status", "start-upgrade", + }, + "alumni": { + "get-dept-tree", "get-info", "list", "query", "search", "list-unaccepted", "get-group", "create-dept", "update-dept", "delete-dept", "update-managers", "add-alumnus", "update-alumnus", "remove-alumnus", "cancel-invite", "create-group", "disband-group", "get-alumni-org-from-graduate", "create-alumni-org", "add-alumni-org-main-admins", + }, + "graduate": { + "query-graduate-years", "query-graduate-depts", "query-graduate-sub-depts", "query-page-graduate-users", "get-task-result", "get-alumni-org", "query-restore-sub-depts", "query-dept-deleted-emps", "search-graduate", "commit-graduate", "all-graduate", "batch-graduate", "delete-and-graduate", "batch-delete-pending", "batch-update-pending", "commit-restore", + }, + "group": { + "query-group-rule", "get-group-rule-schedule", "query-preview-data", "create-group-rule", "delete-group-rule", "enable-group-rule", "disable-group-rule", "set-group-rule-schedule", "execute-group-rule", + }, + } + + for groupName, leaves := range groups { + var groupCmd *cobra.Command + for _, c := range cmd.Commands() { + if c.Name() == groupName { + groupCmd = c + break + } + } + if groupCmd == nil { + t.Fatalf("subcommand group %q not found", groupName) + } + for _, leaf := range leaves { + found := false + for _, c := range groupCmd.Commands() { + if c.Name() == leaf { + found = true + break + } + } + if !found { + t.Errorf("leaf command %q not found under %q", leaf, groupName) + } + } + } + + // stats 分组已移除 + for _, c := range cmd.Commands() { + if c.Name() == "stats" { + t.Error("subcommand group 'stats' should be removed") + } + } +} + +func TestCollegeContactCommand_FindPath(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.AddCommand(newCollegeContactCommand()) + + c, _, err := root.Find([]string{"college-contact", "dept", "get-standard-structure"}) + if err != nil { + t.Fatalf("command path not found: %v", err) + } + if c.Name() != "get-standard-structure" { + t.Errorf("expected leaf 'get-standard-structure', got %q", c.Name()) + } +} + +// newCollegeContactTestRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上 +// 注册全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。 +func newCollegeContactTestRoot() *cobra.Command { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示") + root.AddCommand(newCollegeContactCommand()) + return root +} + +// runDestructiveLeaf 执行不可逆叶子命令并捕获 panic。 +// 单测环境未初始化 products 运行时依赖,若门禁放行后进入 +// CallMCPToolOnServer 会因 deps 为 nil 而 panic,据此区分 +// “被门禁拦截(返回错误)”与“已越过门禁到达 MCP 调用层(panic)”。 +func runDestructiveLeaf(t *testing.T, args ...string) (err error, panicked bool) { + t.Helper() + root := newCollegeContactTestRoot() + root.SetArgs(args) + defer func() { + if r := recover(); r != nil { + panicked = true + } + }() + err = root.Execute() + return err, false +} + +func TestCollegeContactDestructive_RejectedWithoutYes(t *testing.T) { + cases := [][]string{ + {"college-contact", "dept", "delete", "--dept-id", "12345"}, + {"college-contact", "employee", "remove", "--staff-ids", "S12345,S12346"}, + } + for _, args := range cases { + err, panicked := runDestructiveLeaf(t, args...) + if panicked { + t.Fatalf("%v: 未传 --yes 不应到达 MCP 调用层", args) + } + if err == nil { + t.Fatalf("%v: 未传 --yes 应拒绝执行", args) + } + if !strings.Contains(err.Error(), "--yes") { + t.Errorf("%v: 错误信息应提示 --yes,got: %v", args, err) + } + } +} + +func TestCollegeContactDestructive_ProceedsWithYes(t *testing.T) { + cases := [][]string{ + {"college-contact", "dept", "delete", "--dept-id", "12345", "--yes"}, + {"college-contact", "employee", "remove", "--staff-ids", "S12345", "--yes"}, + } + for _, args := range cases { + err, panicked := runDestructiveLeaf(t, args...) + if !panicked { + // 未 panic 意味着未到达 MCP 调用层;若返回的仍是门禁错误则为拦截失败 + if err != nil && strings.Contains(err.Error(), "不可逆操作") { + t.Fatalf("%v: 已传 --yes 仍被门禁拦截: %v", args, err) + } + } + } +} + +func TestCollegeContactConfirmDestructive_Helper(t *testing.T) { + // 无 yes flag(零值安全兼底)→ 拒绝 + bare := &cobra.Command{Use: "x"} + if err := collegeContactConfirmDestructive(bare, "删除部门"); err == nil { + t.Error("无 --yes 应拒绝执行") + } + + // yes=true → 放行 + withYes := &cobra.Command{Use: "x"} + withYes.Flags().Bool("yes", true, "") + if err := collegeContactConfirmDestructive(withYes, "删除部门"); err != nil { + t.Errorf("已传 --yes 应放行,got: %v", err) + } +} + +// withCollegeContactCaller installs a dry-run capture caller so happy-path +// command execution exercises each RunE up to the callMCPToolOnServer dispatch +// without requiring a live MCP transport. In dry-run mode destructive +// commands' confirm gate short-circuits to nil, so no --yes flag is needed. +func withCollegeContactCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{dryRun: true} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +// TestCollegeContactHappyPaths runs every leaf command with required flags only +// and with all optional flags populated, expecting a nil error (dry-run preview). +func TestCollegeContactHappyPaths(t *testing.T) { + withCollegeContactCaller(t) + + cases := [][]string{ + // ── dept ───────────────────────────────────────────── + {"dept", "get-standard-structure"}, + {"dept", "get-standard-structure", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"}, + {"dept", "get-detail", "--dept-id", "123"}, + {"dept", "get-detail", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"}, + {"dept", "get-chain", "--dept-id", "123"}, + {"dept", "get-chain", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"}, + {"dept", "search", "--dept-id", "123", "--keyword", "k"}, + {"dept", "search", "--dept-id", "123", "--keyword", "k", "--staff-id", "S1", "--offset", "0", "--size", "20"}, + {"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"}, + {"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "false", "--dept-id", "5", "--dept-code", "C", "--brief", "b", "--phone", "p"}, + {"dept", "update", "--dept-id", "123", "--dept-type", "college"}, + {"dept", "update", "--dept-id", "123", "--dept-type", "college", "--stru-dept-id", "200", "--super-id", "100", "--create-dept-group", "true", "--name", "X", "--dept-code", "C", "--brief", "b", "--phone", "p"}, + {"dept", "delete", "--dept-id", "123"}, + {"dept", "batch-update-type", "--dept-ids", "100,200", "--target-dept-type", "college"}, + {"dept", "overview"}, + {"dept", "overview", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"}, + + // ── employee ───────────────────────────────────────── + {"employee", "get-detail", "--staff-id", "S1"}, + {"employee", "get-detail", "--staff-id", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "job_number", "--ordering", "asc", "--staff-ids", "s1,s2"}, + {"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true"}, + {"employee", "add", "--emp-type", "college_student", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "1", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--send-active-sms", "false", "--staff-id", "S1", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"}, + {"employee", "remove", "--staff-ids", "S1"}, + {"employee", "remove", "--staff-ids", "S1", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc"}, + {"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher"}, + {"employee", "change-type", "--staff-id", "S1", "--emp-type", "college_teacher", "--dept-id", "1", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"}, + {"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200"}, + {"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "1", "--main-dept-id", "2", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"}, + {"employee", "send-active-sms", "--dept-id", "100"}, + {"employee", "send-active-sms", "--dept-id", "100", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"}, + {"employee", "list-employees", "--dept-id", "123"}, + {"employee", "list-employees", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"}, + {"employee", "list-unaccepted", "--dept-id", "123"}, + {"employee", "list-unaccepted", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--emp-type", "college_student", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"}, + {"employee", "list-unactive", "--dept-id", "123"}, + {"employee", "list-unactive", "--dept-id", "123", "--main-dept-id", "2", "--target-dept-id", "3", "--offset", "0", "--size", "20", "--exclusive-account", "true", "--send-active-sms", "true", "--staff-id", "x", "--name", "n", "--mobile", "m", "--job-number", "j", "--login-id-type", "l", "--order-field", "f", "--ordering", "asc", "--staff-ids", "s1,s2"}, + {"employee", "upgrade-status"}, + {"employee", "upgrade-status", "--dept-id", "123", "--staff-id", "S1", "--keyword", "k", "--offset", "0", "--size", "20"}, + {"employee", "start-upgrade"}, + + // ── alumni ─────────────────────────────────────────── + {"alumni", "get-dept-tree", "--alumni-dept-id", "123"}, + {"alumni", "get-info", "--alumni-dept-id", "123"}, + {"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc"}, + {"alumni", "list", "--alumni-dept-id", "1", "--order-field", "dept_entry", "--ordering", "asc", "--offset", "0", "--size", "20"}, + {"alumni", "query", "--staff-id", "S1"}, + {"alumni", "search", "--keyword", "x"}, + {"alumni", "search", "--keyword", "x", "--offset", "0", "--size", "20"}, + {"alumni", "list-unaccepted", "--alumni-dept-id", "1"}, + {"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "0", "--size", "20"}, + {"alumni", "get-group", "--alumni-dept-id", "1"}, + {"alumni", "create-dept", "--alumni-dept-id", "1", "--dept-name", "D"}, + {"alumni", "update-dept", "--alumni-dept-id", "1", "--dept-name", "D"}, + {"alumni", "delete-dept", "--alumni-dept-id", "1"}, + {"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", "u1,u2"}, + {"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2"}, + {"alumni", "add-alumnus", "--name", "X", "--mobile", "138", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"}, + {"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2"}, + {"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "1,2", "--student-number", "2020", "--email", "e", "--intake", "2020", "--outtake", "2024"}, + {"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"}, + {"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"}, + {"alumni", "create-group", "--alumni-dept-id", "1"}, + {"alumni", "disband-group", "--alumni-dept-id", "1"}, + {"alumni", "get-alumni-org-from-graduate"}, + {"alumni", "create-alumni-org", "--org-name", "O"}, + {"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"}, + + // ── graduate ───────────────────────────────────────── + {"graduate", "query-graduate-years"}, + {"graduate", "query-graduate-depts", "--dept-id", "1"}, + {"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "2026"}, + {"graduate", "query-graduate-sub-depts", "--dept-id", "1"}, + {"graduate", "query-page-graduate-users", "--dept-id", "1"}, + {"graduate", "query-page-graduate-users", "--dept-id", "1", "--graduate-year", "2026", "--offset", "0", "--size", "20"}, + {"graduate", "get-task-result", "--request-no", "r1"}, + {"graduate", "get-task-result", "--request-no", "r1", "--type", "GRADUATE"}, + {"graduate", "get-alumni-org"}, + {"graduate", "query-restore-sub-depts", "--dept-id", "1"}, + {"graduate", "query-dept-deleted-emps", "--dept-id", "1"}, + {"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "0", "--size", "20"}, + {"graduate", "search-graduate", "--keyword", "x"}, + {"graduate", "search-graduate", "--keyword", "x", "--offset", "0", "--size", "20"}, + {"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"}, + {"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026", "--request-no", "r1"}, + {"graduate", "all-graduate", "--graduate-year", "2026"}, + {"graduate", "all-graduate", "--graduate-year", "2026", "--request-no", "r1"}, + {"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"}, + {"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"}, + {"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"}, + {"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"}, + {"graduate", "commit-restore", "--graduate-dept-ids", "1,2"}, + {"graduate", "commit-restore", "--graduate-dept-ids", "1,2", "--request-no", "r1"}, + + // ── group ──────────────────────────────────────────── + {"group", "query-group-rule"}, + {"group", "query-group-rule", "--name", "N", "--offset", "0", "--size", "20"}, + {"group", "get-group-rule-schedule"}, + {"group", "query-preview-data"}, + {"group", "query-preview-data", "--offset", "0", "--size", "20"}, + {"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college"}, + {"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "true"}, + {"group", "delete-group-rule", "--rule-id", "1"}, + {"group", "enable-group-rule", "--rule-id", "1"}, + {"group", "disable-group-rule", "--rule-id", "1"}, + {"group", "set-group-rule-schedule"}, + {"group", "set-group-rule-schedule", "--cron", "0 0 2 * * ?"}, + {"group", "execute-group-rule"}, + } + + for _, args := range cases { + root := newCollegeContactCommand() + if err := executeCommand(root, args...); err != nil { + t.Errorf("%v: expected nil error, got: %v", args, err) + } + } +} + +// TestCollegeContactValidationErrors exercises every validation-error branch: +// missing required flags, non-integer int flags, invalid bool flags, and +// empty-after-split CSV lists. Each case must return a non-nil error. +func TestCollegeContactValidationErrors(t *testing.T) { + withCollegeContactCaller(t) + + cases := [][]string{ + // ── dept ───────────────────────────────────────────── + {"dept", "get-standard-structure", "--dept-id", "abc"}, + {"dept", "get-standard-structure", "--offset", "abc"}, + {"dept", "get-standard-structure", "--size", "abc"}, + {"dept", "get-detail"}, + {"dept", "get-detail", "--dept-id", "abc"}, + {"dept", "get-detail", "--dept-id", "1", "--offset", "abc"}, + {"dept", "get-detail", "--dept-id", "1", "--size", "abc"}, + {"dept", "get-chain"}, + {"dept", "get-chain", "--dept-id", "abc"}, + {"dept", "get-chain", "--dept-id", "1", "--offset", "abc"}, + {"dept", "get-chain", "--dept-id", "1", "--size", "abc"}, + {"dept", "search"}, + {"dept", "search", "--dept-id", "abc", "--keyword", "k"}, + {"dept", "search", "--dept-id", "1"}, + {"dept", "search", "--dept-id", "1", "--keyword", "k", "--offset", "abc"}, + {"dept", "search", "--dept-id", "1", "--keyword", "k", "--size", "abc"}, + {"dept", "create"}, + {"dept", "create", "--super-id", "abc"}, + {"dept", "create", "--super-id", "100"}, + {"dept", "create", "--super-id", "100", "--stru-dept-id", "abc"}, + {"dept", "create", "--super-id", "100", "--stru-dept-id", "200"}, + {"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X"}, + {"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college"}, + {"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "maybe"}, + {"dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true", "--dept-id", "abc"}, + {"dept", "update"}, + {"dept", "update", "--dept-id", "abc"}, + {"dept", "update", "--dept-id", "1"}, + {"dept", "update", "--dept-id", "1", "--dept-type", "college", "--stru-dept-id", "abc"}, + {"dept", "update", "--dept-id", "1", "--dept-type", "college", "--super-id", "abc"}, + {"dept", "update", "--dept-id", "1", "--dept-type", "college", "--create-dept-group", "maybe"}, + {"dept", "delete"}, + {"dept", "delete", "--dept-id", "abc"}, + {"dept", "batch-update-type"}, + {"dept", "batch-update-type", "--dept-ids", "abc", "--target-dept-type", "college"}, + {"dept", "batch-update-type", "--dept-ids", ",,", "--target-dept-type", "college"}, + {"dept", "batch-update-type", "--dept-ids", "1,2"}, + {"dept", "overview", "--dept-id", "abc"}, + {"dept", "overview", "--offset", "abc"}, + {"dept", "overview", "--size", "abc"}, + + // ── employee ───────────────────────────────────────── + {"employee", "get-detail"}, + {"employee", "get-detail", "--staff-id", "S1", "--dept-id", "abc"}, + {"employee", "get-detail", "--staff-id", "S1", "--exclusive-account", "maybe"}, + {"employee", "add"}, + {"employee", "add", "--emp-type", "x"}, + {"employee", "add", "--emp-type", "x", "--main-dept-id", "abc"}, + {"employee", "add", "--emp-type", "x", "--main-dept-id", "100"}, + {"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "maybe"}, + {"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--dept-id", "abc"}, + {"employee", "add", "--emp-type", "x", "--main-dept-id", "100", "--exclusive-account", "true", "--send-active-sms", "maybe"}, + {"employee", "remove"}, + {"employee", "remove", "--staff-ids", ",,"}, + {"employee", "remove", "--staff-ids", "S1", "--dept-id", "abc"}, + {"employee", "remove", "--staff-ids", "S1", "--exclusive-account", "maybe"}, + {"employee", "change-type"}, + {"employee", "change-type", "--staff-id", "S1"}, + {"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--dept-id", "abc"}, + {"employee", "change-type", "--staff-id", "S1", "--emp-type", "t", "--exclusive-account", "maybe"}, + {"employee", "change-dept"}, + {"employee", "change-dept", "--staff-id", "S1"}, + {"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "abc"}, + {"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--dept-id", "abc"}, + {"employee", "change-dept", "--staff-id", "S1", "--target-dept-id", "200", "--exclusive-account", "maybe"}, + {"employee", "send-active-sms"}, + {"employee", "send-active-sms", "--dept-id", "abc"}, + {"employee", "send-active-sms", "--dept-id", "1", "--main-dept-id", "abc"}, + {"employee", "send-active-sms", "--dept-id", "1", "--exclusive-account", "maybe"}, + {"employee", "list-employees"}, + {"employee", "list-employees", "--dept-id", "abc"}, + {"employee", "list-employees", "--dept-id", "1", "--main-dept-id", "abc"}, + {"employee", "list-employees", "--dept-id", "1", "--exclusive-account", "maybe"}, + {"employee", "list-unaccepted"}, + {"employee", "list-unaccepted", "--dept-id", "abc"}, + {"employee", "list-unaccepted", "--dept-id", "1", "--main-dept-id", "abc"}, + {"employee", "list-unaccepted", "--dept-id", "1", "--exclusive-account", "maybe"}, + {"employee", "list-unactive"}, + {"employee", "list-unactive", "--dept-id", "abc"}, + {"employee", "list-unactive", "--dept-id", "1", "--main-dept-id", "abc"}, + {"employee", "list-unactive", "--dept-id", "1", "--exclusive-account", "maybe"}, + {"employee", "upgrade-status", "--dept-id", "abc"}, + {"employee", "upgrade-status", "--offset", "abc"}, + {"employee", "upgrade-status", "--size", "abc"}, + + // ── alumni ─────────────────────────────────────────── + {"alumni", "get-dept-tree"}, + {"alumni", "get-dept-tree", "--alumni-dept-id", "abc"}, + {"alumni", "get-info"}, + {"alumni", "get-info", "--alumni-dept-id", "abc"}, + {"alumni", "list"}, + {"alumni", "list", "--alumni-dept-id", "abc", "--order-field", "f", "--ordering", "asc"}, + {"alumni", "list", "--alumni-dept-id", "1"}, + {"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f"}, + {"alumni", "list", "--alumni-dept-id", "1", "--order-field", "f", "--ordering", "asc", "--offset", "abc"}, + {"alumni", "query"}, + {"alumni", "search"}, + {"alumni", "search", "--keyword", "x", "--offset", "abc"}, + {"alumni", "list-unaccepted"}, + {"alumni", "list-unaccepted", "--alumni-dept-id", "abc"}, + {"alumni", "list-unaccepted", "--alumni-dept-id", "1", "--offset", "abc"}, + {"alumni", "get-group"}, + {"alumni", "get-group", "--alumni-dept-id", "abc"}, + {"alumni", "create-dept"}, + {"alumni", "create-dept", "--alumni-dept-id", "abc", "--dept-name", "D"}, + {"alumni", "create-dept", "--alumni-dept-id", "1"}, + {"alumni", "update-dept"}, + {"alumni", "update-dept", "--alumni-dept-id", "abc", "--dept-name", "D"}, + {"alumni", "update-dept", "--alumni-dept-id", "1"}, + {"alumni", "delete-dept"}, + {"alumni", "delete-dept", "--alumni-dept-id", "abc"}, + {"alumni", "update-managers"}, + {"alumni", "update-managers", "--alumni-dept-id", "abc", "--admin-user-ids", "u"}, + {"alumni", "update-managers", "--alumni-dept-id", "1"}, + {"alumni", "update-managers", "--alumni-dept-id", "1", "--admin-user-ids", ",,"}, + {"alumni", "add-alumnus"}, + {"alumni", "add-alumnus", "--name", "X"}, + {"alumni", "add-alumnus", "--name", "X", "--mobile", "m"}, + {"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", "abc"}, + {"alumni", "add-alumnus", "--name", "X", "--mobile", "m", "--dept-ids", ",,"}, + {"alumni", "update-alumnus"}, + {"alumni", "update-alumnus", "--staff-id", "S1"}, + {"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X"}, + {"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", "abc"}, + {"alumni", "update-alumnus", "--staff-id", "S1", "--name", "X", "--dept-ids", ",,"}, + {"alumni", "remove-alumnus"}, + {"alumni", "remove-alumnus", "--staff-id", "S1"}, + {"alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "abc"}, + {"alumni", "cancel-invite"}, + {"alumni", "cancel-invite", "--alumni-dept-id", "abc", "--staff-ids", "s"}, + {"alumni", "cancel-invite", "--alumni-dept-id", "1"}, + {"alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", ",,"}, + {"alumni", "create-group"}, + {"alumni", "create-group", "--alumni-dept-id", "abc"}, + {"alumni", "disband-group"}, + {"alumni", "disband-group", "--alumni-dept-id", "abc"}, + {"alumni", "create-alumni-org"}, + {"alumni", "add-alumni-org-main-admins"}, + {"alumni", "add-alumni-org-main-admins", "--admin-user-ids", ",,"}, + + // ── graduate ───────────────────────────────────────── + {"graduate", "query-graduate-depts"}, + {"graduate", "query-graduate-depts", "--dept-id", "abc"}, + {"graduate", "query-graduate-depts", "--dept-id", "1", "--graduate-year", "abc"}, + {"graduate", "query-graduate-sub-depts"}, + {"graduate", "query-graduate-sub-depts", "--dept-id", "abc"}, + {"graduate", "query-page-graduate-users"}, + {"graduate", "query-page-graduate-users", "--dept-id", "abc"}, + {"graduate", "query-page-graduate-users", "--dept-id", "1", "--offset", "abc"}, + {"graduate", "get-task-result"}, + {"graduate", "query-restore-sub-depts"}, + {"graduate", "query-restore-sub-depts", "--dept-id", "abc"}, + {"graduate", "query-dept-deleted-emps"}, + {"graduate", "query-dept-deleted-emps", "--dept-id", "abc"}, + {"graduate", "query-dept-deleted-emps", "--dept-id", "1", "--offset", "abc"}, + {"graduate", "search-graduate"}, + {"graduate", "search-graduate", "--keyword", "x", "--offset", "abc"}, + {"graduate", "commit-graduate"}, + {"graduate", "commit-graduate", "--graduate-dept-ids", "abc"}, + {"graduate", "commit-graduate", "--graduate-dept-ids", ",,"}, + {"graduate", "commit-graduate", "--graduate-dept-ids", "1,2"}, + {"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "abc"}, + {"graduate", "all-graduate"}, + {"graduate", "all-graduate", "--graduate-year", "abc"}, + {"graduate", "batch-graduate"}, + {"graduate", "batch-graduate", "--dept-id", "abc"}, + {"graduate", "batch-graduate", "--dept-id", "1"}, + {"graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", ",,"}, + {"graduate", "delete-and-graduate"}, + {"graduate", "delete-and-graduate", "--dept-id", "abc"}, + {"graduate", "delete-and-graduate", "--dept-id", "1"}, + {"graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", ",,"}, + {"graduate", "batch-delete-pending"}, + {"graduate", "batch-delete-pending", "--dept-id", "abc"}, + {"graduate", "batch-delete-pending", "--dept-id", "1"}, + {"graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", ",,"}, + {"graduate", "batch-update-pending"}, + {"graduate", "batch-update-pending", "--dept-id", "abc"}, + {"graduate", "batch-update-pending", "--dept-id", "1"}, + {"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", ",,"}, + {"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1"}, + {"graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1", "--graduate-year", "abc"}, + {"graduate", "commit-restore"}, + {"graduate", "commit-restore", "--graduate-dept-ids", "abc"}, + {"graduate", "commit-restore", "--graduate-dept-ids", ",,"}, + + // ── group ──────────────────────────────────────────── + {"group", "query-group-rule", "--offset", "abc"}, + {"group", "query-group-rule", "--size", "abc"}, + {"group", "query-preview-data", "--offset", "abc"}, + {"group", "query-preview-data", "--size", "abc"}, + {"group", "create-group-rule"}, + {"group", "create-group-rule", "--name", "X"}, + {"group", "create-group-rule", "--name", "X", "--tag-code", "T"}, + {"group", "create-group-rule", "--name", "X", "--tag-code", "T", "--dept-type", "college", "--auto-admin", "maybe"}, + {"group", "delete-group-rule"}, + {"group", "delete-group-rule", "--rule-id", "abc"}, + {"group", "enable-group-rule"}, + {"group", "enable-group-rule", "--rule-id", "abc"}, + {"group", "disable-group-rule"}, + {"group", "disable-group-rule", "--rule-id", "abc"}, + } + + for _, args := range cases { + root := newCollegeContactCommand() + if err := executeCommand(root, args...); err == nil { + t.Errorf("%v: expected non-nil error, got nil", args) + } + } +} + +// TestCollegeContactDestructiveConfirmGate covers the `return err` branch of +// every destructive leaf command's confirm gate. A non-dry-run caller is +// installed (so collegeContactConfirmDestructive does not short-circuit) and +// each command is run with valid required flags but without --yes, so the +// confirm gate returns an error that the RunE closure propagates. +func TestCollegeContactDestructiveConfirmGate(t *testing.T) { + caller := &recruitCaptureCaller{dryRun: false} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + + cases := [][]string{ + {"college-contact", "alumni", "delete-dept", "--alumni-dept-id", "1"}, + {"college-contact", "alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"}, + {"college-contact", "alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"}, + {"college-contact", "alumni", "disband-group", "--alumni-dept-id", "1"}, + {"college-contact", "graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"}, + {"college-contact", "graduate", "all-graduate", "--graduate-year", "2026"}, + {"college-contact", "graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"}, + {"college-contact", "graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"}, + {"college-contact", "graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"}, + {"college-contact", "graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"}, + {"college-contact", "graduate", "commit-restore", "--graduate-dept-ids", "1,2"}, + {"college-contact", "group", "delete-group-rule", "--rule-id", "1"}, + {"college-contact", "group", "execute-group-rule"}, + } + + for _, args := range cases { + root := newCollegeContactTestRoot() + root.SetArgs(args) + err := root.Execute() + if err == nil { + t.Errorf("%v: expected confirm-gate error without --yes, got nil", args) + continue + } + if !strings.Contains(err.Error(), "不可逆操作") { + t.Errorf("%v: expected 不可逆操作 gate error, got: %v", args, err) + } + } +} + +// withCollegeContactDispatchCaller installs a non-dry-run capture caller so +// commands go through the full dispatch path (deps.Caller.CallTool) and we can +// verify the productID, tool name, and args passed to callMCPToolOnServer. +func withCollegeContactDispatchCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +// TestCollegeContactDispatch verifies that representative commands from each +// group dispatch to the correct MCP tool with the expected productID and args. +func TestCollegeContactDispatch(t *testing.T) { + type dispatchCase struct { + name string + args []string + wantTool string + wantProd string + checkArgs func(t *testing.T, args map[string]any) + } + + cases := []dispatchCase{ + { + name: "dept get-standard-structure", + args: []string{"college-contact", "dept", "get-standard-structure"}, + wantTool: "get_college_standard_structure", + wantProd: "college-contact", + }, + { + name: "dept get-detail", + args: []string{"college-contact", "dept", "get-detail", "--dept-id", "123"}, + wantTool: "get_college_dept_detail", + wantProd: "college-contact", + checkArgs: func(t *testing.T, args map[string]any) { + input := args["input"].(map[string]any) + if input["deptId"] != int64(123) { + t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"]) + } + }, + }, + { + name: "dept create", + args: []string{"college-contact", "dept", "create", "--super-id", "100", "--stru-dept-id", "200", "--name", "X", "--dept-type", "college", "--create-dept-group", "true"}, + wantTool: "create_college_contact_dept", + wantProd: "college-contact", + }, + { + name: "employee get-detail", + args: []string{"college-contact", "employee", "get-detail", "--staff-id", "S1"}, + wantTool: "get_employee_detail", + wantProd: "college-contact", + checkArgs: func(t *testing.T, args map[string]any) { + input := args["input"].(map[string]any) + if input["staffId"] != "S1" { + t.Errorf("staffId = %v, want S1", input["staffId"]) + } + }, + }, + { + name: "alumni get-dept-tree", + args: []string{"college-contact", "alumni", "get-dept-tree", "--alumni-dept-id", "123"}, + wantTool: "get_alumni_dept_tree", + wantProd: "college-contact", + checkArgs: func(t *testing.T, args map[string]any) { + input := args["input"].(map[string]any) + if input["alumniDeptId"] != int64(123) { + t.Errorf("alumniDeptId = %v (%T), want int64(123)", input["alumniDeptId"], input["alumniDeptId"]) + } + }, + }, + { + name: "graduate query-graduate-years", + args: []string{"college-contact", "graduate", "query-graduate-years"}, + wantTool: "query_graduate_years", + wantProd: "college-contact", + }, + { + name: "group query-group-rule", + args: []string{"college-contact", "group", "query-group-rule"}, + wantTool: "query_group_rule", + wantProd: "college-contact", + }, + { + name: "dept delete with --yes", + args: []string{"college-contact", "dept", "delete", "--dept-id", "123", "--yes"}, + wantTool: "delete_college_contact_dept", + wantProd: "college-contact", + checkArgs: func(t *testing.T, args map[string]any) { + input := args["input"].(map[string]any) + if input["deptId"] != int64(123) { + t.Errorf("deptId = %v (%T), want int64(123)", input["deptId"], input["deptId"]) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := withCollegeContactDispatchCaller(t) + + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示") + root.AddCommand(newCollegeContactCommand()) + root.SetArgs(tc.args) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if caller.productID != tc.wantProd { + t.Errorf("productID = %q, want %q", caller.productID, tc.wantProd) + } + if caller.tool != tc.wantTool { + t.Errorf("tool = %q, want %q", caller.tool, tc.wantTool) + } + if tc.checkArgs != nil { + tc.checkArgs(t, caller.args) + } + }) + } +} diff --git a/internal/helpers/edu_app.go b/internal/helpers/edu_app.go new file mode 100644 index 00000000..0117bd31 --- /dev/null +++ b/internal/helpers/edu_app.go @@ -0,0 +1,3546 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +// ────────────────────────────────────────────────────────── +// dws edu-app — 家校应用 +// 共 42 个工具,按 message / task / report / notice / circle / card / diploma / homework 分组 +// ────────────────────────────────────────────────────────── + +func newEduAppCommand() *cobra.Command { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: "edu-app", + Selection: contract.ProductSelectionDecl{ + AgentSummary: "家校应用:AI消息总结、家校任务查询、成绩单管理、通知确认、班级圈动态、打卡管理、奖状颁发、作业管理", + UseWhen: []string{ + "用户要管理钉钉家校应用中的消息总结、任务、成绩单、通知、班级圈、打卡、奖状或作业。", + }, + AvoidWhen: []string{ + "家校通讯录用 edu-contact;班级群用 edu-group;家庭群用 edu-familygroup。", + }, + }, + }) + root := &cobra.Command{ + Use: "edu-app", + Short: "家校应用", + Long: `钉钉家校应用:AI消息总结、家校任务查询、成绩单管理、通知确认、奖状颁发、作业管理等。`, + Hidden: true, + RunE: groupRunE, + } + + // ════════════════════════════════════════════════════════════ + // message 子命令组 — AI 消息 + // ════════════════════════════════════════════════════════════ + + messageCmd := &cobra.Command{Use: "message", Short: "AI 消息管理", RunE: groupRunE} + + messageSummaryListCmd := &cobra.Command{ + Use: "summary-list", + Short: "获取AI消息总结列表", + Long: `查询指定群下用户的AI消息总结任务列表。 +--target-role 取值:guardian(家长)、student(学生)。 +--status 取值:0(未处理)、1(已处理)。`, + Example: ` dws edu-app message summary-list --class-id 12345 --cid cidxxx --target-role guardian --status 0 + dws edu-app message summary-list --class-id 12345 --cid cidxxx --target-role student --status 1 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + classID, err := eduAppRequiredIntFlag(cmd, "class-id") + if err != nil { + return err + } + cid, _ := cmd.Flags().GetString("cid") + cid = strings.TrimSpace(cid) + if cid == "" { + return fmt.Errorf("--cid 为必填参数") + } + targetRole, _ := cmd.Flags().GetString("target-role") + targetRole = strings.TrimSpace(targetRole) + if targetRole == "" { + return fmt.Errorf("--target-role 为必填参数") + } + statusStr, _ := cmd.Flags().GetString("status") + statusStr = strings.TrimSpace(statusStr) + if statusStr == "" { + return fmt.Errorf("--status 为必填参数") + } + status, err := strconv.ParseInt(statusStr, 10, 64) + if err != nil { + return fmt.Errorf("--status 须为整数: %w", err) + } + return callMCPToolOnServer("edu-app", "get_ai_message_summary_list", map[string]any{ + "input": map[string]any{ + "classId": classID, + "cid": cid, + "targetRole": targetRole, + "status": status, + }, + }) + }, + } + DeclareLeafMetadata(messageSummaryListCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "get_ai_message_summary_list", + CanonicalPath: "edu-app.get_ai_message_summary_list", + CLIPath: "edu-app message summary-list", + PrimaryCLIPath: "edu-app message summary-list", + }, + Description: "获取AI消息总结列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "get_ai_message_summary_list"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "获取AI消息总结列表", + UseWhen: []string{"需要查询指定群下用户的AI消息总结任务列表时"}, + AvoidWhen: []string{"查询家校任务用 task publish-list"}, + Examples: []string{ + "dws edu-app message summary-list --class-id 12345 --cid cidxxx --target-role guardian --status 0 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "cid", Property: "input.cid", Required: boolPtr(true)}, + {Name: "target-role", Property: "input.targetRole", Required: boolPtr(true)}, + {Name: "status", Property: "input.status", Required: boolPtr(true)}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // task 子命令组 — 家校任务 + // ════════════════════════════════════════════════════════════ + + taskCmd := &cobra.Command{Use: "task", Short: "家校任务管理", RunE: groupRunE} + + taskPublishListCmd := &cobra.Command{ + Use: "publish-list", + Short: "查询老师发布的任务列表", + Long: `查询当前老师已发布的家校任务列表,支持分页。 +--task-sources 可选值(逗号分隔):EDU_HOMEWORK(家校本)、EDU_CARD(打卡)、EDU_NOTICE(通知)、EDU_SR(成绩单)、EDU_DIPLOMA(奖状),不传时默认查询全部类型。`, + Example: ` dws edu-app task publish-list + dws edu-app task publish-list --limit 10 --need-statistic + dws edu-app task publish-list --task-sources EDU_HOMEWORK,EDU_NOTICE -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + if v, _ := cmd.Flags().GetInt64("cursor"); cmd.Flags().Changed("cursor") { + input["cursor"] = v + } + if v, _ := cmd.Flags().GetInt64("limit"); cmd.Flags().Changed("limit") { + input["pageSize"] = v + } + if v, _ := cmd.Flags().GetBool("need-statistic"); v { + input["needStatistic"] = true + } + if raw, _ := cmd.Flags().GetString("task-sources"); strings.TrimSpace(raw) != "" { + input["taskSourceList"] = eduAppParseCSV(raw) + } + return callMCPToolOnServer("edu-app", "query_publish_task", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(taskPublishListCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_publish_task", + CanonicalPath: "edu-app.query_publish_task", + CLIPath: "edu-app task publish-list", + PrimaryCLIPath: "edu-app task publish-list", + }, + Description: "查询老师发布的任务列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_publish_task"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询老师发布的家校任务列表", + UseWhen: []string{"需要查询当前老师已发布的家校任务列表时"}, + AvoidWhen: []string{"查询全部任务用 task all-list;查询学生待办用 task student-list"}, + Examples: []string{ + "dws edu-app task publish-list --limit 10 --need-statistic --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "cursor", Property: "input.cursor"}, + {Name: "limit", Property: "input.pageSize"}, + {Name: "need-statistic", Property: "input.needStatistic"}, + {Name: "task-sources", Property: "input.taskSourceList"}, + }, + }, + }) + + taskAllListCmd := &cobra.Command{ + Use: "all-list", + Short: "查询全部家校任务列表", + Long: `查询组织内全部家校任务列表,支持分页,仅限老师角色调用。 +--task-sources 可选值(逗号分隔):EDU_HOMEWORK、EDU_CARD、EDU_NOTICE、EDU_SR、EDU_DIPLOMA,不传时默认查询全部类型。`, + Example: ` dws edu-app task all-list --biz-id 12345 + dws edu-app task all-list --biz-id 12345 --limit 10 --need-statistic -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + bizID, err := eduAppRequiredIntFlag(cmd, "biz-id") + if err != nil { + return err + } + input := map[string]any{"bizId": bizID} + if v, _ := cmd.Flags().GetInt64("cursor"); cmd.Flags().Changed("cursor") { + input["cursor"] = v + } + if v, _ := cmd.Flags().GetInt64("limit"); cmd.Flags().Changed("limit") { + input["pageSize"] = v + } + if v, _ := cmd.Flags().GetBool("need-statistic"); v { + input["needStatistic"] = true + } + if raw, _ := cmd.Flags().GetString("task-sources"); strings.TrimSpace(raw) != "" { + input["taskSourceList"] = eduAppParseCSV(raw) + } + return callMCPToolOnServer("edu-app", "query_all_task", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(taskAllListCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_all_task", + CanonicalPath: "edu-app.query_all_task", + CLIPath: "edu-app task all-list", + PrimaryCLIPath: "edu-app task all-list", + }, + Description: "查询全部家校任务列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_all_task"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询组织内全部家校任务列表", + UseWhen: []string{"需要查询组织内全部家校任务列表时"}, + AvoidWhen: []string{"查询老师发布的任务用 task publish-list;查询学生待办用 task student-list"}, + Examples: []string{ + "dws edu-app task all-list --biz-id 12345 --limit 10 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "biz-id", Property: "input.bizId", Required: boolPtr(true)}, + {Name: "cursor", Property: "input.cursor"}, + {Name: "limit", Property: "input.pageSize"}, + {Name: "need-statistic", Property: "input.needStatistic"}, + {Name: "task-sources", Property: "input.taskSourceList"}, + }, + }, + }) + + taskStudentListCmd := &cobra.Command{ + Use: "student-list", + Short: "查询学生待办任务列表", + Long: `查询多个学生的待办任务列表,支持分页,适用于家长/学生角色调用。 +--students 为 JSON 数组,每个元素需包含 userId 和 bizId(班级ID)。 +--query-all 为 false 时只查待办任务,为 true 时查询全部任务(含已完成)。 +--task-sources 可选值(逗号分隔):EDU_HOMEWORK、EDU_CARD、EDU_NOTICE、EDU_SR、EDU_DIPLOMA。`, + Example: ` dws edu-app task student-list --students '[{"userId":"uid1","bizId":"12345"}]' --query-all=false + dws edu-app task student-list --students '[{"userId":"uid1","bizId":"12345"},{"userId":"uid2","bizId":"67890"}]' --query-all=true -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + studentsRaw, _ := cmd.Flags().GetString("students") + studentsRaw = strings.TrimSpace(studentsRaw) + if studentsRaw == "" { + return fmt.Errorf("--students 为必填参数") + } + var students []map[string]any + if err := json.Unmarshal([]byte(studentsRaw), &students); err != nil { + return fmt.Errorf("--students JSON 格式错误: %w", err) + } + queryAll, _ := cmd.Flags().GetBool("query-all") + input := map[string]any{ + "students": students, + "queryAll": queryAll, + } + if v, _ := cmd.Flags().GetString("cursor"); strings.TrimSpace(v) != "" { + input["cursor"] = v + } + if v, _ := cmd.Flags().GetString("limit"); strings.TrimSpace(v) != "" { + input["pageSize"] = v + } + if raw, _ := cmd.Flags().GetString("task-sources"); strings.TrimSpace(raw) != "" { + input["taskSourceList"] = eduAppParseCSV(raw) + } + return callMCPToolOnServer("edu-app", "query_multi_user_sub_tasks", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(taskStudentListCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_multi_user_sub_tasks", + CanonicalPath: "edu-app.query_multi_user_sub_tasks", + CLIPath: "edu-app task student-list", + PrimaryCLIPath: "edu-app task student-list", + }, + Description: "查询学生待办任务列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_multi_user_sub_tasks"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询多个学生的待办任务列表", + UseWhen: []string{"需要查询学生的待办或全部家校任务时"}, + AvoidWhen: []string{"查询老师发布的任务用 task publish-list"}, + Examples: []string{ + "dws edu-app task student-list --students '[{\"userId\":\"uid1\",\"bizId\":\"12345\"}]' --query-all=false --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "students", Property: "input.students", Required: boolPtr(true)}, + {Name: "query-all", Property: "input.queryAll"}, + {Name: "cursor", Property: "input.cursor"}, + {Name: "limit", Property: "input.pageSize"}, + {Name: "task-sources", Property: "input.taskSourceList"}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // report 子命令组 — 成绩单 + // ════════════════════════════════════════════════════════════ + + reportCmd := &cobra.Command{Use: "report", Short: "成绩单管理", RunE: groupRunE} + + reportGetCmd := &cobra.Command{ + Use: "get", + Short: "获取成绩单列表", + Long: `按成绩单ID列表批量查询成绩单信息。`, + Example: ` dws edu-app report get --ids 1001,1002,1003 + dws edu-app report get --ids 1001 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + raw, _ := cmd.Flags().GetString("ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--ids 为必填参数") + } + ids, err := eduAppParseIntCSV(raw) + if err != nil { + return fmt.Errorf("--ids 须为逗号分隔的整数列表: %w", err) + } + return callMCPToolOnServer("edu-app", "get_report", map[string]any{ + "input": map[string]any{"schoolReportIdList": ids}, + }) + }, + } + DeclareLeafMetadata(reportGetCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "get_report", + CanonicalPath: "edu-app.get_report", + CLIPath: "edu-app report get", + PrimaryCLIPath: "edu-app report get", + }, + Description: "获取成绩单列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "get_report"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按成绩单ID列表批量查询成绩单信息", + UseWhen: []string{"需要按成绩单ID批量查询成绩单信息时"}, + AvoidWhen: []string{"查询老师创建的成绩单用 report by-teacher;查询学生成绩用 report by-student-detail"}, + Examples: []string{ + "dws edu-app report get --ids 1001,1002,1003 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "ids", Property: "input.schoolReportIdList", Required: boolPtr(true)}, + }, + }, + }) + + reportByTeacherCmd := &cobra.Command{ + Use: "by-teacher", + Short: "查询老师创建的成绩单列表", + Long: `查询指定老师创建的成绩单列表,支持分页。 +--status 取值:0(未发布)、1(已发布),不传则查询所有状态。`, + Example: ` dws edu-app report by-teacher + dws edu-app report by-teacher --status 1 --page 1 --limit 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + pageNumber, _ := cmd.Flags().GetInt64("page") + if pageNumber <= 0 { + pageNumber = 1 + } + input["pageNumber"] = pageNumber + pageSize, _ := cmd.Flags().GetInt64("limit") + if pageSize <= 0 { + pageSize = 20 + } + input["pageSize"] = pageSize + if cmd.Flags().Changed("status") { + statusVal, _ := cmd.Flags().GetInt64("status") + input["status"] = statusVal + } + return callMCPToolOnServer("edu-app", "list_report_by_teacher", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(reportByTeacherCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "list_report_by_teacher", + CanonicalPath: "edu-app.list_report_by_teacher", + CLIPath: "edu-app report by-teacher", + PrimaryCLIPath: "edu-app report by-teacher", + }, + Description: "查询老师创建的成绩单列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "list_report_by_teacher"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询老师创建的成绩单列表", + UseWhen: []string{"需要查询指定老师创建的成绩单列表时"}, + AvoidWhen: []string{"查询学生收到的成绩单用 report by-student-list"}, + Examples: []string{ + "dws edu-app report by-teacher --status 1 --page 1 --limit 20 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "page", Property: "input.pageNumber"}, + {Name: "limit", Property: "input.pageSize"}, + {Name: "status", Property: "input.status"}, + }, + }, + }) + + reportByClassCmd := &cobra.Command{ + Use: "by-class", + Short: "查询班级学生成绩明细", + Long: `按成绩单ID和班级ID查询该班级学生的成绩明细。 +返回学生成绩明细列表及阅读状态汇总统计(totalCount/readCount/unreadCount)。`, + Example: ` dws edu-app report by-class --report-id 1001 --class-id 12345 + dws edu-app report by-class --report-id 1001 --class-id 12345 --student-ids uid1,uid2 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + reportIDStr, _ := cmd.Flags().GetString("report-id") + reportIDStr = strings.TrimSpace(reportIDStr) + if reportIDStr == "" { + return fmt.Errorf("--report-id 为必填参数") + } + reportID, err := strconv.ParseInt(reportIDStr, 10, 64) + if err != nil { + return fmt.Errorf("--report-id 须为整数: %w", err) + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + input := map[string]any{ + "schoolReportId": reportID, + "classId": classID, + } + if raw, _ := cmd.Flags().GetString("student-ids"); strings.TrimSpace(raw) != "" { + input["studentIdList"] = eduAppParseCSV(raw) + } + return callMCPToolOnServer("edu-app", "query_detail_by_class", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(reportByClassCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_detail_by_class", + CanonicalPath: "edu-app.query_detail_by_class", + CLIPath: "edu-app report by-class", + PrimaryCLIPath: "edu-app report by-class", + }, + Description: "查询班级学生成绩明细", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_detail_by_class"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按成绩单ID和班级ID查询学生成绩明细", + UseWhen: []string{"需要按成绩单ID和班级ID查询班级学生的成绩明细时"}, + AvoidWhen: []string{"查询单个学生成绩用 report by-student-detail"}, + Examples: []string{ + "dws edu-app report by-class --report-id 1001 --class-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "report-id", Property: "input.schoolReportId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "student-ids", Property: "input.studentIdList"}, + }, + }, + }) + + reportByStudentListCmd := &cobra.Command{ + Use: "by-student-list", + Short: "查询学生收到的成绩单列表", + Long: `查询指定学生收到的已发布成绩单列表,支持分页。`, + Example: ` dws edu-app report by-student-list --class-id 12345 --student-id uid1 + dws edu-app report by-student-list --class-id 12345 --student-id uid1 --page 1 --limit 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + studentID, _ := cmd.Flags().GetString("student-id") + studentID = strings.TrimSpace(studentID) + if studentID == "" { + return fmt.Errorf("--student-id 为必填参数") + } + pageNumber, _ := cmd.Flags().GetInt64("page") + if pageNumber <= 0 { + pageNumber = 1 + } + pageSize, _ := cmd.Flags().GetInt64("limit") + if pageSize <= 0 { + pageSize = 20 + } + return callMCPToolOnServer("edu-app", "list_report_by_student", map[string]any{ + "input": map[string]any{ + "classId": classID, + "studentId": studentID, + "pageNumber": pageNumber, + "pageSize": pageSize, + }, + }) + }, + } + DeclareLeafMetadata(reportByStudentListCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "list_report_by_student", + CanonicalPath: "edu-app.list_report_by_student", + CLIPath: "edu-app report by-student-list", + PrimaryCLIPath: "edu-app report by-student-list", + }, + Description: "查询学生收到的成绩单列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "list_report_by_student"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询学生收到的已发布成绩单列表", + UseWhen: []string{"需要查询指定学生收到的成绩单列表时"}, + AvoidWhen: []string{"查询单个学生成绩明细用 report by-student-detail"}, + Examples: []string{ + "dws edu-app report by-student-list --class-id 12345 --student-id uid1 --page 1 --limit 20 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "student-id", Property: "input.studentId", Required: boolPtr(true)}, + {Name: "page", Property: "input.pageNumber"}, + {Name: "limit", Property: "input.pageSize"}, + }, + }, + }) + + reportByStudentDetailCmd := &cobra.Command{ + Use: "by-student-detail", + Short: "查询学生成绩明细", + Long: `按成绩单ID查询指定学生的成绩明细。 +返回该学生的成绩明细列表,包含各科成绩及已读状态(readStatus:1-未读、2-已读)。`, + Example: ` dws edu-app report by-student-detail --report-id 1001 --student-id uid1 --class-id 12345 + dws edu-app report by-student-detail --report-id 1001 --student-id uid1 --class-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + reportIDStr, _ := cmd.Flags().GetString("report-id") + reportIDStr = strings.TrimSpace(reportIDStr) + if reportIDStr == "" { + return fmt.Errorf("--report-id 为必填参数") + } + reportID, err := strconv.ParseInt(reportIDStr, 10, 64) + if err != nil { + return fmt.Errorf("--report-id 须为整数: %w", err) + } + studentID, _ := cmd.Flags().GetString("student-id") + studentID = strings.TrimSpace(studentID) + if studentID == "" { + return fmt.Errorf("--student-id 为必填参数") + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + return callMCPToolOnServer("edu-app", "query_detail_by_student", map[string]any{ + "input": map[string]any{ + "schoolReportId": reportID, + "studentId": studentID, + "classId": classID, + }, + }) + }, + } + DeclareLeafMetadata(reportByStudentDetailCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_detail_by_student", + CanonicalPath: "edu-app.query_detail_by_student", + CLIPath: "edu-app report by-student-detail", + PrimaryCLIPath: "edu-app report by-student-detail", + }, + Description: "查询学生成绩明细", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_detail_by_student"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按成绩单ID查询指定学生的成绩明细", + UseWhen: []string{"需要查询指定学生的成绩明细时"}, + AvoidWhen: []string{"查询班级整体成绩用 report by-class"}, + Examples: []string{ + "dws edu-app report by-student-detail --report-id 1001 --student-id uid1 --class-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "report-id", Property: "input.schoolReportId", Required: boolPtr(true)}, + {Name: "student-id", Property: "input.studentId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // notice 子命令组 — 通知 + // ════════════════════════════════════════════════════════════ + + noticeCmd := &cobra.Command{Use: "notice", Short: "通知管理", RunE: groupRunE} + + noticeConfirmCmd := &cobra.Command{ + Use: "confirm", + Short: "确认收到通知", + Long: `家长端确认收到通知。返回确认结果(confirmed: true 表示确认成功)。`, + Example: ` dws edu-app notice confirm --notice-id nid1 --student-id uid1 + dws edu-app notice confirm --notice-id nid1 --student-id uid1 --parent-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + noticeID, _ := cmd.Flags().GetString("notice-id") + noticeID = strings.TrimSpace(noticeID) + if noticeID == "" { + return fmt.Errorf("--notice-id 为必填参数") + } + studentID, _ := cmd.Flags().GetString("student-id") + studentID = strings.TrimSpace(studentID) + if studentID == "" { + return fmt.Errorf("--student-id 为必填参数") + } + input := map[string]any{ + "noticeId": noticeID, + "studentId": studentID, + } + if v, _ := cmd.Flags().GetString("device-id"); strings.TrimSpace(v) != "" { + input["deviceId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("parent-name"); strings.TrimSpace(v) != "" { + input["parentName"] = strings.TrimSpace(v) + } + if cmd.Flags().Changed("update-sign") { + updateSign, _ := cmd.Flags().GetBool("update-sign") + input["updateSign"] = updateSign + } + return callMCPToolOnServer("edu-app", "confirm_notice", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(noticeConfirmCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "confirm_notice", + CanonicalPath: "edu-app.confirm_notice", + CLIPath: "edu-app notice confirm", + PrimaryCLIPath: "edu-app notice confirm", + }, + Description: "确认收到通知", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "confirm_notice"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "家长端确认收到通知", + UseWhen: []string{"需要确认收到家校通知时"}, + AvoidWhen: []string{"查询通知详情用 notice get;查询确认状态用 notice confirm-status"}, + Examples: []string{ + "dws edu-app notice confirm --notice-id nid1 --student-id uid1 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "notice-id", Property: "input.noticeId", Required: boolPtr(true)}, + {Name: "student-id", Property: "input.studentId", Required: boolPtr(true)}, + {Name: "device-id", Property: "input.deviceId"}, + {Name: "parent-name", Property: "input.parentName"}, + {Name: "update-sign", Property: "input.updateSign"}, + }, + }, + }) + + noticeCreateCmd := &cobra.Command{ + Use: "create", + Short: "创建并发布通知", + Long: `创建并发布一条家校通知。 +--identifer 为幂等字段(必填),建议格式:orgId-staffId-UUID。 +--content 为通知内容(必填)。 +--class-ids、--class-names 为逗号分隔的班级列表,--class-selected-students 为 JSON 对象。`, + Example: ` dws edu-app notice create --identifer org1-staff1-uuid --content "明天放假" + dws edu-app notice create --identifer org1-staff1-uuid --title "放假通知" --content "明天放假" --class-ids 12345,67890 --target-role guardian --is-signed true -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + identifer, _ := cmd.Flags().GetString("identifer") + identifer = strings.TrimSpace(identifer) + if identifer == "" { + return fmt.Errorf("--identifer 为必填参数") + } + content, _ := cmd.Flags().GetString("content") + content = strings.TrimSpace(content) + if content == "" { + return fmt.Errorf("--content 为必填参数") + } + input := map[string]any{ + "identifer": identifer, + "content": content, + } + if v, _ := cmd.Flags().GetString("title"); strings.TrimSpace(v) != "" { + input["title"] = strings.TrimSpace(v) + } + if raw, _ := cmd.Flags().GetString("class-ids"); strings.TrimSpace(raw) != "" { + input["classIds"] = eduAppParseCSV(raw) + } + if raw, _ := cmd.Flags().GetString("class-names"); strings.TrimSpace(raw) != "" { + input["classNames"] = eduAppParseCSV(raw) + } + if raw, _ := cmd.Flags().GetString("class-selected-students"); strings.TrimSpace(raw) != "" { + var obj any + if err := json.Unmarshal([]byte(raw), &obj); err != nil { + return fmt.Errorf("--class-selected-students JSON 格式错误: %w", err) + } + input["classSelectedStudents"] = obj + } + if v, _ := cmd.Flags().GetString("type"); strings.TrimSpace(v) != "" { + input["type"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("scope"); strings.TrimSpace(v) != "" { + input["scope"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("target-role"); strings.TrimSpace(v) != "" { + input["targetRole"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("is-signed"); strings.TrimSpace(v) != "" { + input["isSigned"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("photo"); strings.TrimSpace(v) != "" { + input["photo"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("media"); strings.TrimSpace(v) != "" { + input["media"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("audio"); strings.TrimSpace(v) != "" { + input["audio"] = strings.TrimSpace(v) + } + if cmd.Flags().Changed("send-ding") { + v, _ := cmd.Flags().GetBool("send-ding") + input["sendDing"] = v + } + if v, _ := cmd.Flags().GetString("scheduled-release"); strings.TrimSpace(v) != "" { + input["scheduledRelease"] = strings.TrimSpace(v) + } + if cmd.Flags().Changed("notice-deadline") { + v, _ := cmd.Flags().GetInt64("notice-deadline") + input["noticeDeadline"] = v + } + if v, _ := cmd.Flags().GetString("notice-deadline-open"); strings.TrimSpace(v) != "" { + input["noticeDeadlineOpen"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("notice-deadline-setting"); strings.TrimSpace(v) != "" { + input["noticeDeadlineSetting"] = strings.TrimSpace(v) + } + if raw, _ := cmd.Flags().GetString("attributes"); strings.TrimSpace(raw) != "" { + var obj any + if err := json.Unmarshal([]byte(raw), &obj); err != nil { + return fmt.Errorf("--attributes JSON 格式错误: %w", err) + } + input["attributes"] = obj + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "create_notice", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(noticeCreateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "create_notice", + CanonicalPath: "edu-app.create_notice", + CLIPath: "edu-app notice create", + PrimaryCLIPath: "edu-app notice create", + }, + Description: "创建并发布通知", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "create_notice"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建并发布一条家校通知", + UseWhen: []string{"需要创建并发布家校通知时"}, + AvoidWhen: []string{"删除通知用 notice delete;查询通知用 notice get"}, + Examples: []string{ + "dws edu-app notice create --identifer org1-staff1-uuid --content \"明天放假\" --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "identifer", Property: "input.identifer", Required: boolPtr(true)}, + {Name: "content", Property: "input.content", Required: boolPtr(true)}, + {Name: "title", Property: "input.title"}, + {Name: "class-ids", Property: "input.classIds"}, + {Name: "class-names", Property: "input.classNames"}, + {Name: "class-selected-students", Property: "input.classSelectedStudents"}, + {Name: "type", Property: "input.type"}, + {Name: "scope", Property: "input.scope"}, + {Name: "target-role", Property: "input.targetRole"}, + {Name: "is-signed", Property: "input.isSigned"}, + {Name: "photo", Property: "input.photo"}, + {Name: "media", Property: "input.media"}, + {Name: "audio", Property: "input.audio"}, + {Name: "send-ding", Property: "input.sendDing"}, + {Name: "scheduled-release", Property: "input.scheduledRelease"}, + {Name: "notice-deadline", Property: "input.noticeDeadline"}, + {Name: "notice-deadline-open", Property: "input.noticeDeadlineOpen"}, + {Name: "notice-deadline-setting", Property: "input.noticeDeadlineSetting"}, + {Name: "attributes", Property: "input.attributes"}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + noticeDeleteCmd := &cobra.Command{ + Use: "delete", + Short: "删除通知", + Long: `删除指定的家校通知。 +--notice-id 为通知 ID(必填),--user-name 为可选参数。`, + Example: ` dws edu-app notice delete --notice-id 12345 + dws edu-app notice delete --notice-id 12345 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + noticeID, err := eduAppRequiredIntFlag(cmd, "notice-id") + if err != nil { + return err + } + input := map[string]any{ + "noticeId": noticeID, + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "delete_notice", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(noticeDeleteCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "delete_notice", + CanonicalPath: "edu-app.delete_notice", + CLIPath: "edu-app notice delete", + PrimaryCLIPath: "edu-app notice delete", + }, + Description: "删除通知", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "delete_notice"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "删除指定的家校通知", + UseWhen: []string{"需要删除指定的家校通知时"}, + AvoidWhen: []string{"查询通知详情用 notice get;创建通知用 notice create"}, + Examples: []string{ + "dws edu-app notice delete --notice-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "notice-id", Property: "input.noticeId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + noticeListByTeacherCmd := &cobra.Command{ + Use: "list-by-teacher", + Short: "查询老师发布的通知列表", + Long: `查询老师发布的家校通知列表,支持分页和筛选。 +所有参数均为可选,--class-id 按班级筛选,--type 按通知类型筛选,--status 按通知状态筛选。`, + Example: ` dws edu-app notice list-by-teacher + dws edu-app notice list-by-teacher --class-id 67890 --type SCHOOL --status FINISHED --page 1 --page-size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + if v, _ := cmd.Flags().GetString("class-id"); strings.TrimSpace(v) != "" { + input["classId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("type"); strings.TrimSpace(v) != "" { + input["type"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("status"); strings.TrimSpace(v) != "" { + input["status"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + pageNumber, _ := cmd.Flags().GetInt64("page") + if pageNumber > 0 { + input["pageNumber"] = pageNumber + } else { + input["pageNumber"] = 1 + } + pageSize, _ := cmd.Flags().GetInt64("page-size") + if pageSize > 0 { + input["pageSize"] = pageSize + } else { + input["pageSize"] = 20 + } + return callMCPToolOnServer("edu-app", "list_notice_by_teacher", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(noticeListByTeacherCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "list_notice_by_teacher", + CanonicalPath: "edu-app.list_notice_by_teacher", + CLIPath: "edu-app notice list-by-teacher", + PrimaryCLIPath: "edu-app notice list-by-teacher", + }, + Description: "查询老师发布的通知列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "list_notice_by_teacher"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询老师发布的家校通知列表", + UseWhen: []string{"需要查询老师发布的家校通知列表时"}, + AvoidWhen: []string{"查询学生通知用 notice list-by-student"}, + Examples: []string{ + "dws edu-app notice list-by-teacher --class-id 67890 --page 1 --page-size 20 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-id", Property: "input.classId"}, + {Name: "type", Property: "input.type"}, + {Name: "status", Property: "input.status"}, + {Name: "user-name", Property: "input.userName"}, + {Name: "page", Property: "input.pageNumber"}, + {Name: "page-size", Property: "input.pageSize"}, + }, + }, + }) + + noticeGetCmd := &cobra.Command{ + Use: "get", + Short: "查询通知详情", + Long: `按通知 ID 查询家校通知详情。 +--notice-id 为通知 ID(必填),--user-name 为可选参数。`, + Example: ` dws edu-app notice get --notice-id 12345 + dws edu-app notice get --notice-id 12345 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + noticeID, err := eduAppRequiredIntFlag(cmd, "notice-id") + if err != nil { + return err + } + input := map[string]any{ + "noticeId": noticeID, + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "get_notice", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(noticeGetCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "get_notice", + CanonicalPath: "edu-app.get_notice", + CLIPath: "edu-app notice get", + PrimaryCLIPath: "edu-app notice get", + }, + Description: "查询通知详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "get_notice"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按通知ID查询家校通知详情", + UseWhen: []string{"需要查询指定通知的详情时"}, + AvoidWhen: []string{"查询通知列表用 notice list-by-teacher 或 notice list-by-student"}, + Examples: []string{ + "dws edu-app notice get --notice-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "notice-id", Property: "input.noticeId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + noticeConfirmStatusCmd := &cobra.Command{ + Use: "confirm-status", + Short: "查询通知确认状态", + Long: `按通知 ID 和班级 ID 查询通知的确认状态(已确认/未确认人数及明细)。 +--notice-id、--class-id 为必填参数,--status、--page、--page-size、--user-name 为可选参数。`, + Example: ` dws edu-app notice confirm-status --notice-id 12345 --class-id 67890 + dws edu-app notice confirm-status --notice-id 12345 --class-id 67890 --status CONFIRMED --page 1 --page-size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + noticeID, err := eduAppRequiredIntFlag(cmd, "notice-id") + if err != nil { + return err + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + input := map[string]any{ + "noticeId": noticeID, + "classId": classID, + } + if v, _ := cmd.Flags().GetString("status"); strings.TrimSpace(v) != "" { + input["status"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + pageNumber, _ := cmd.Flags().GetInt64("page") + if pageNumber > 0 { + input["pageNumber"] = pageNumber + } else { + input["pageNumber"] = 1 + } + pageSize, _ := cmd.Flags().GetInt64("page-size") + if pageSize > 0 { + input["pageSize"] = pageSize + } else { + input["pageSize"] = 20 + } + return callMCPToolOnServer("edu-app", "query_notice_confirm_status", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(noticeConfirmStatusCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_notice_confirm_status", + CanonicalPath: "edu-app.query_notice_confirm_status", + CLIPath: "edu-app notice confirm-status", + PrimaryCLIPath: "edu-app notice confirm-status", + }, + Description: "查询通知确认状态", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_notice_confirm_status"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按通知ID和班级ID查询通知确认状态", + UseWhen: []string{"需要查询通知的确认状态时"}, + AvoidWhen: []string{"确认通知用 notice confirm;查询通知详情用 notice get"}, + Examples: []string{ + "dws edu-app notice confirm-status --notice-id 12345 --class-id 67890 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "notice-id", Property: "input.noticeId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "status", Property: "input.status"}, + {Name: "user-name", Property: "input.userName"}, + {Name: "page", Property: "input.pageNumber"}, + {Name: "page-size", Property: "input.pageSize"}, + }, + }, + }) + + noticeListByStudentCmd := &cobra.Command{ + Use: "list-by-student", + Short: "查询学生通知列表", + Long: `按学生 ID 和班级 ID 查询学生收到的家校通知列表,支持分页和状态筛选。 +--student-id、--class-id 为必填参数,--status、--page、--page-size、--user-name 为可选参数。`, + Example: ` dws edu-app notice list-by-student --student-id uid1 --class-id 67890 + dws edu-app notice list-by-student --student-id uid1 --class-id 67890 --status FINISHED --page 1 --page-size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + studentID, _ := cmd.Flags().GetString("student-id") + studentID = strings.TrimSpace(studentID) + if studentID == "" { + return fmt.Errorf("--student-id 为必填参数") + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + input := map[string]any{ + "studentId": studentID, + "classId": classID, + } + if v, _ := cmd.Flags().GetString("status"); strings.TrimSpace(v) != "" { + input["status"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + pageNumber, _ := cmd.Flags().GetInt64("page") + if pageNumber > 0 { + input["pageNumber"] = pageNumber + } else { + input["pageNumber"] = 1 + } + pageSize, _ := cmd.Flags().GetInt64("page-size") + if pageSize > 0 { + input["pageSize"] = pageSize + } else { + input["pageSize"] = 20 + } + return callMCPToolOnServer("edu-app", "list_notice_by_student", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(noticeListByStudentCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "list_notice_by_student", + CanonicalPath: "edu-app.list_notice_by_student", + CLIPath: "edu-app notice list-by-student", + PrimaryCLIPath: "edu-app notice list-by-student", + }, + Description: "查询学生通知列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "list_notice_by_student"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按学生ID和班级ID查询学生收到的通知列表", + UseWhen: []string{"需要查询学生收到的家校通知列表时"}, + AvoidWhen: []string{"查询老师发布的通知用 notice list-by-teacher"}, + Examples: []string{ + "dws edu-app notice list-by-student --student-id uid1 --class-id 67890 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "student-id", Property: "input.studentId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "status", Property: "input.status"}, + {Name: "user-name", Property: "input.userName"}, + {Name: "page", Property: "input.pageNumber"}, + {Name: "page-size", Property: "input.pageSize"}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // circle 子命令组 — 班级圈 + // ════════════════════════════════════════════════════════════ + + circleCmd := &cobra.Command{Use: "circle", Short: "班级圈管理", RunE: groupRunE} + + circlePostsCmd := &cobra.Command{ + Use: "posts", + Short: "查询学生班级圈动态", + Long: `查询指定学生在班级圈中的动态(文字、图片等成长记录)。 +--target-role 取值:guardian(家长视角)、student(学生视角)。 +返回每条动态的文字内容、图片URL列表、发布者姓名、发布时间、评论数、点赞数等信息。`, + Example: ` dws edu-app circle posts --class-id 12345 --student-id uid1 --target-role guardian + dws edu-app circle posts --class-id 12345 --student-id uid1 --target-role student -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + studentID, _ := cmd.Flags().GetString("student-id") + studentID = strings.TrimSpace(studentID) + if studentID == "" { + return fmt.Errorf("--student-id 为必填参数") + } + targetRole, _ := cmd.Flags().GetString("target-role") + targetRole = strings.TrimSpace(targetRole) + if targetRole == "" { + return fmt.Errorf("--target-role 为必填参数") + } + return callMCPToolOnServer("edu-app", "query_student_circle_posts", map[string]any{ + "input": map[string]any{ + "classId": classID, + "studentId": studentID, + "targetRole": targetRole, + }, + }) + }, + } + DeclareLeafMetadata(circlePostsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_student_circle_posts", + CanonicalPath: "edu-app.query_student_circle_posts", + CLIPath: "edu-app circle posts", + PrimaryCLIPath: "edu-app circle posts", + }, + Description: "查询学生班级圈动态", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_student_circle_posts"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询学生在班级圈中的动态", + UseWhen: []string{"需要查询学生在班级圈中的成长记录动态时"}, + AvoidWhen: []string{"查询家校任务用 task;查询作业用 homework"}, + Examples: []string{ + "dws edu-app circle posts --class-id 12345 --student-id uid1 --target-role guardian --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "student-id", Property: "input.studentId", Required: boolPtr(true)}, + {Name: "target-role", Property: "input.targetRole", Required: boolPtr(true)}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // card 子命令组 — 打卡 + // ════════════════════════════════════════════════════════════ + + cardCmd := &cobra.Command{Use: "card", Short: "打卡管理", RunE: groupRunE} + + cardUpdateCmd := &cobra.Command{ + Use: "update", + Short: "修改打卡标题或内容", + Long: `修改指定打卡任务的标题或内容(仅允许修改这两项,其他字段忽略)。仅老师可调用且操作人必须为打卡创建者。 +--title 与 --content 至少传一个。 +--identifier 建议格式:orgId-staffId-UUID,用于幂等去重。`, + Example: ` dws edu-app card update --card-id 12345 --identifier org1-staff1-uuid --title "新标题" + dws edu-app card update --card-id 12345 --identifier org1-staff1-uuid --content "新内容" --should-send-update-msg true -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + cardID, err := eduAppRequiredIntFlag(cmd, "card-id") + if err != nil { + return err + } + identifier, _ := cmd.Flags().GetString("identifier") + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return fmt.Errorf("--identifier 为必填参数") + } + title, _ := cmd.Flags().GetString("title") + title = strings.TrimSpace(title) + content, _ := cmd.Flags().GetString("content") + content = strings.TrimSpace(content) + if title == "" && content == "" { + return fmt.Errorf("--title 与 --content 至少传一个") + } + input := map[string]any{ + "cardId": cardID, + "identifier": identifier, + } + if title != "" { + input["title"] = title + } + if content != "" { + input["content"] = content + } + if cmd.Flags().Changed("should-send-update-msg") { + v, _ := cmd.Flags().GetBool("should-send-update-msg") + input["shouldSendUpdateMsg"] = v + } + return callMCPToolOnServer("edu-app", "update_card", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(cardUpdateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "update_card", + CanonicalPath: "edu-app.update_card", + CLIPath: "edu-app card update", + PrimaryCLIPath: "edu-app card update", + }, + Description: "修改打卡标题或内容", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "update_card"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "修改打卡任务的标题或内容", + UseWhen: []string{"需要修改打卡任务的标题或内容时"}, + AvoidWhen: []string{"结束打卡用 card end;查询打卡用 card list"}, + Examples: []string{ + "dws edu-app card update --card-id 12345 --identifier org1-staff1-uuid --title \"新标题\" --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "card-id", Property: "input.cardId", Required: boolPtr(true)}, + {Name: "identifier", Property: "input.identifier", Required: boolPtr(true)}, + {Name: "title", Property: "input.title"}, + {Name: "content", Property: "input.content"}, + {Name: "should-send-update-msg", Property: "input.shouldSendUpdateMsg"}, + }, + }, + }) + + cardEndCmd := &cobra.Command{ + Use: "end", + Short: "提前结束打卡任务", + Long: `提前结束指定打卡任务,仅老师可调用且操作人必须为打卡创建者。适用于老师在打卡周期未到期时手动终止打卡任务。`, + Example: ` dws edu-app card end --card-id 12345 + dws edu-app card end --card-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + cardID, err := eduAppRequiredIntFlag(cmd, "card-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-app", "end_card", map[string]any{ + "input": map[string]any{ + "cardId": cardID, + }, + }) + }, + } + DeclareLeafMetadata(cardEndCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "end_card", + CanonicalPath: "edu-app.end_card", + CLIPath: "edu-app card end", + PrimaryCLIPath: "edu-app card end", + }, + Description: "提前结束打卡任务", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "end_card"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "提前结束打卡任务", + UseWhen: []string{"需要提前结束指定打卡任务时"}, + AvoidWhen: []string{"修改打卡用 card update;查询打卡用 card list"}, + Examples: []string{ + "dws edu-app card end --card-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "card-id", Property: "input.cardId", Required: boolPtr(true)}, + }, + }, + }) + + cardListCmd := &cobra.Command{ + Use: "list", + Short: "查询打卡列表", + Long: `查询孩子或本人的全部打卡列表(含进行中与已完结),支持分页。 +角色由 uid 真实身份自动判断:学生查自己、家长查孩子,老师调用返回空列表。 +--status 取值:FINISH(已完结)、UNFINISH(进行中)。`, + Example: ` dws edu-app card list --status UNFINISH + dws edu-app card list --status FINISH --class-id 12345 --page 1 --limit 10 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + status, _ := cmd.Flags().GetString("status") + status = strings.TrimSpace(status) + if status == "" { + return fmt.Errorf("--status 为必填参数") + } + if status != "FINISH" && status != "UNFINISH" { + return fmt.Errorf("--status 取值必须为 FINISH 或 UNFINISH") + } + input := map[string]any{ + "status": status, + } + if cmd.Flags().Changed("class-id") { + v, _ := cmd.Flags().GetInt64("class-id") + input["classId"] = v + } + pageNo, _ := cmd.Flags().GetInt64("page") + if pageNo <= 0 { + pageNo = 1 + } + input["pageNo"] = pageNo + pageSize, _ := cmd.Flags().GetInt64("limit") + if pageSize <= 0 { + pageSize = 10 + } + input["pageSize"] = pageSize + return callMCPToolOnServer("edu-app", "get_card_list", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(cardListCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "get_card_list", + CanonicalPath: "edu-app.get_card_list", + CLIPath: "edu-app card list", + PrimaryCLIPath: "edu-app card list", + }, + Description: "查询打卡列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "get_card_list"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询打卡列表", + UseWhen: []string{"需要查询打卡列表时"}, + AvoidWhen: []string{"查询打卡完成情况用 card user-statistic;查询打卡详情用 card finish-info"}, + Examples: []string{ + "dws edu-app card list --status UNFINISH --page 1 --limit 10 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "status", Property: "input.status", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId"}, + {Name: "page", Property: "input.pageNo"}, + {Name: "limit", Property: "input.pageSize"}, + }, + }, + }) + + cardUserStatisticCmd := &cobra.Command{ + Use: "user-statistic", + Short: "查询班级打卡完成/未完成人员", + Long: `查询指定打卡任务下某班级的已完成或未完成人员列表,支持分页。 +仅老师/班主任可调用(角色由 uid 真实身份自动判断)。 +--finish 为 true 时查已完成人员,为 false 时查未完成人员。 +注意:--class-id 为 string 类型。`, + Example: ` dws edu-app card user-statistic --card-id 12345 --task-code code1 --class-id cid1 + dws edu-app card user-statistic --card-id 12345 --task-code code1 --class-id cid1 --finish --page 1 --limit 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + cardID, err := eduAppRequiredIntFlag(cmd, "card-id") + if err != nil { + return err + } + taskCode, _ := cmd.Flags().GetString("task-code") + taskCode = strings.TrimSpace(taskCode) + if taskCode == "" { + return fmt.Errorf("--task-code 为必填参数") + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + input := map[string]any{ + "cardId": cardID, + "taskCode": taskCode, + "classId": classID, + } + if cmd.Flags().Changed("finish") { + v, _ := cmd.Flags().GetBool("finish") + input["finish"] = v + } + pageNo, _ := cmd.Flags().GetInt64("page") + if pageNo <= 0 { + pageNo = 1 + } + input["pageNo"] = pageNo + pageSize, _ := cmd.Flags().GetInt64("limit") + if pageSize <= 0 { + pageSize = 10 + } + input["pageSize"] = pageSize + return callMCPToolOnServer("edu-app", "get_card_user_statistic", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(cardUserStatisticCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "get_card_user_statistic", + CanonicalPath: "edu-app.get_card_user_statistic", + CLIPath: "edu-app card user-statistic", + PrimaryCLIPath: "edu-app card user-statistic", + }, + Description: "查询班级打卡完成/未完成人员", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "get_card_user_statistic"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询班级打卡完成/未完成人员列表", + UseWhen: []string{"需要查询打卡任务下某班级的完成或未完成人员时"}, + AvoidWhen: []string{"查询打卡列表用 card list;查询打卡详情用 card finish-info"}, + Examples: []string{ + "dws edu-app card user-statistic --card-id 12345 --task-code code1 --class-id cid1 --finish --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "card-id", Property: "input.cardId", Required: boolPtr(true)}, + {Name: "task-code", Property: "input.taskCode", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "finish", Property: "input.finish"}, + {Name: "page", Property: "input.pageNo"}, + {Name: "limit", Property: "input.pageSize"}, + }, + }, + }) + + cardFinishInfoCmd := &cobra.Command{ + Use: "finish-info", + Short: "查询打卡详情及完成进度", + Long: `查询指定打卡任务的详情及完成进度。 +--target-role 取值:teacher(老师视角)、guardian(家长/学生视角),未传时按 uid 真实身份自动推断。 +当 targetRole 为 guardian 时,可通过 --student-id 指定查看某个孩子的进度。`, + Example: ` dws edu-app card finish-info --card-id 12345 --card-biz-id bid1 + dws edu-app card finish-info --card-id 12345 --card-biz-id bid1 --target-role guardian --student-id stu1 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + cardID, err := eduAppRequiredIntFlag(cmd, "card-id") + if err != nil { + return err + } + cardBizID, _ := cmd.Flags().GetString("card-biz-id") + cardBizID = strings.TrimSpace(cardBizID) + if cardBizID == "" { + return fmt.Errorf("--card-biz-id 为必填参数") + } + input := map[string]any{ + "cardId": cardID, + "cardBizId": cardBizID, + } + if v, _ := cmd.Flags().GetString("target-role"); strings.TrimSpace(v) != "" { + role := strings.TrimSpace(v) + if role != "teacher" && role != "guardian" && role != "student" && role != "headmaster" { + return fmt.Errorf("--target-role 取值必须为 teacher / guardian / student / headmaster") + } + input["targetRole"] = role + } + if v, _ := cmd.Flags().GetString("student-id"); strings.TrimSpace(v) != "" { + input["studentId"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "get_card_finish_info", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(cardFinishInfoCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "get_card_finish_info", + CanonicalPath: "edu-app.get_card_finish_info", + CLIPath: "edu-app card finish-info", + PrimaryCLIPath: "edu-app card finish-info", + }, + Description: "查询打卡详情及完成进度", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "get_card_finish_info"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询打卡任务详情及完成进度", + UseWhen: []string{"需要查询打卡任务的详情及完成进度时"}, + AvoidWhen: []string{"查询打卡列表用 card list;查询打卡人员统计用 card user-statistic"}, + Examples: []string{ + "dws edu-app card finish-info --card-id 12345 --card-biz-id bid1 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "card-id", Property: "input.cardId", Required: boolPtr(true)}, + {Name: "card-biz-id", Property: "input.cardBizId", Required: boolPtr(true)}, + {Name: "target-role", Property: "input.targetRole"}, + {Name: "student-id", Property: "input.studentId"}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // diploma 子命令组 — 奖状 + // ════════════════════════════════════════════════════════════ + + diplomaCmd := &cobra.Command{Use: "diploma", Short: "奖状管理", RunE: groupRunE} + + diplomaCreateCmd := &cobra.Command{ + Use: "create", + Short: "创建并颁发奖状", + Long: `创建并颁发一张奖状。 +--identifier 为幂等标识(必填),建议格式:orgId-staffId-UUID,重复提交同一 identifier 不会重复创建。 +--select-class 为选择的班级及学生 JSON 数组,--attributes 为扩展属性 JSON 对象。 +返回创建结果:success 表示是否创建成功,diplomaId 为新创建的奖状 ID。`, + Example: ` dws edu-app diploma create --identifier org1-staff1-uuid --content "期末三好学生" --user-name 张三 + dws edu-app diploma create --identifier org1-staff1-uuid --title "三好学生" --content "表现优秀" --user-name 张三 --unit-name "实验小学" --publish-time "2026-07-29" -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + identifier, _ := cmd.Flags().GetString("identifier") + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return fmt.Errorf("--identifier 为必填参数") + } + content, _ := cmd.Flags().GetString("content") + content = strings.TrimSpace(content) + if content == "" { + return fmt.Errorf("--content 为必填参数") + } + userName, _ := cmd.Flags().GetString("user-name") + userName = strings.TrimSpace(userName) + if userName == "" { + return fmt.Errorf("--user-name 为必填参数") + } + input := map[string]any{ + "identifier": identifier, + "content": content, + "userName": userName, + } + if v, _ := cmd.Flags().GetString("title"); strings.TrimSpace(v) != "" { + input["title"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("unit-name"); strings.TrimSpace(v) != "" { + input["unitName"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("tag"); strings.TrimSpace(v) != "" { + input["tag"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("photo"); strings.TrimSpace(v) != "" { + input["photo"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("publish-time"); strings.TrimSpace(v) != "" { + input["publishTime"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("biz-code"); strings.TrimSpace(v) != "" { + input["bizCode"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("biz-category"); strings.TrimSpace(v) != "" { + input["bizCategory"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("msg-type"); strings.TrimSpace(v) != "" { + input["msgType"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("template-url"); strings.TrimSpace(v) != "" { + input["templateUrl"] = strings.TrimSpace(v) + } + if raw, _ := cmd.Flags().GetString("class-ids"); strings.TrimSpace(raw) != "" { + input["classIds"] = eduAppParseCSV(raw) + } + if raw, _ := cmd.Flags().GetString("select-class"); strings.TrimSpace(raw) != "" { + var selectClass []map[string]any + if err := json.Unmarshal([]byte(raw), &selectClass); err != nil { + return fmt.Errorf("--select-class JSON 格式错误: %w", err) + } + input["selectClass"] = selectClass + } + if raw, _ := cmd.Flags().GetString("attributes"); strings.TrimSpace(raw) != "" { + var attributes map[string]any + if err := json.Unmarshal([]byte(raw), &attributes); err != nil { + return fmt.Errorf("--attributes JSON 格式错误: %w", err) + } + input["attributes"] = attributes + } + return callMCPToolOnServer("edu-app", "create_diploma", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(diplomaCreateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "create_diploma", + CanonicalPath: "edu-app.create_diploma", + CLIPath: "edu-app diploma create", + PrimaryCLIPath: "edu-app diploma create", + }, + Description: "创建并颁发奖状", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "create_diploma"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建并颁发一张奖状", + UseWhen: []string{"需要创建并颁发奖状时"}, + AvoidWhen: []string{"查询奖状用 diploma get;删除奖状用 diploma delete"}, + Examples: []string{ + "dws edu-app diploma create --identifier org1-staff1-uuid --content \"期末三好学生\" --user-name 张三 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "identifier", Property: "input.identifier", Required: boolPtr(true)}, + {Name: "content", Property: "input.content", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName", Required: boolPtr(true)}, + {Name: "title", Property: "input.title"}, + {Name: "unit-name", Property: "input.unitName"}, + {Name: "tag", Property: "input.tag"}, + {Name: "photo", Property: "input.photo"}, + {Name: "publish-time", Property: "input.publishTime"}, + {Name: "biz-code", Property: "input.bizCode"}, + {Name: "biz-category", Property: "input.bizCategory"}, + {Name: "msg-type", Property: "input.msgType"}, + {Name: "template-url", Property: "input.templateUrl"}, + {Name: "class-ids", Property: "input.classIds"}, + {Name: "select-class", Property: "input.selectClass"}, + {Name: "attributes", Property: "input.attributes"}, + }, + }, + }) + + diplomaReadCmd := &cobra.Command{ + Use: "read", + Short: "标记奖状为已读", + Long: `标记指定奖状为已读状态。 +--diploma-id 为奖状 ID(必填),--class-id、--student-id、--user-name 为可选参数。`, + Example: ` dws edu-app diploma read --diploma-id 12345 + dws edu-app diploma read --diploma-id 12345 --class-id 67890 --student-id uid1 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + diplomaID, err := eduAppRequiredIntFlag(cmd, "diploma-id") + if err != nil { + return err + } + input := map[string]any{ + "diplomaId": diplomaID, + } + if v, _ := cmd.Flags().GetString("class-id"); strings.TrimSpace(v) != "" { + input["classId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("student-id"); strings.TrimSpace(v) != "" { + input["studentId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "read_diploma", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(diplomaReadCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "read_diploma", + CanonicalPath: "edu-app.read_diploma", + CLIPath: "edu-app diploma read", + PrimaryCLIPath: "edu-app diploma read", + }, + Description: "标记奖状为已读", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "read_diploma"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "标记指定奖状为已读状态", + UseWhen: []string{"需要标记奖状为已读时"}, + AvoidWhen: []string{"查询奖状详情用 diploma get;查询奖状统计用 diploma statistics"}, + Examples: []string{ + "dws edu-app diploma read --diploma-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "diploma-id", Property: "input.diplomaId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId"}, + {Name: "student-id", Property: "input.studentId"}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + diplomaListByTeacherCmd := &cobra.Command{ + Use: "list-by-teacher", + Short: "查询老师创建的奖状列表", + Long: `查询当前老师创建的奖状列表,支持分页。 +--status 为奖状状态筛选,--tag 为标签筛选,--user-name 为获奖人姓名筛选。`, + Example: ` dws edu-app diploma list-by-teacher + dws edu-app diploma list-by-teacher --page 1 --limit 20 --tag "三好学生" -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + pageNumber, _ := cmd.Flags().GetInt64("page") + if pageNumber <= 0 { + pageNumber = 1 + } + input["pageNumber"] = pageNumber + pageSize, _ := cmd.Flags().GetInt64("limit") + if pageSize <= 0 { + pageSize = 20 + } + input["pageSize"] = pageSize + if v, _ := cmd.Flags().GetString("status"); strings.TrimSpace(v) != "" { + input["status"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("tag"); strings.TrimSpace(v) != "" { + input["tag"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "list_diploma_by_teacher", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(diplomaListByTeacherCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "list_diploma_by_teacher", + CanonicalPath: "edu-app.list_diploma_by_teacher", + CLIPath: "edu-app diploma list-by-teacher", + PrimaryCLIPath: "edu-app diploma list-by-teacher", + }, + Description: "查询老师创建的奖状列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "list_diploma_by_teacher"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询老师创建的奖状列表", + UseWhen: []string{"需要查询老师创建的奖状列表时"}, + AvoidWhen: []string{"查询学生收到的奖状用 diploma list-by-student"}, + Examples: []string{ + "dws edu-app diploma list-by-teacher --page 1 --limit 20 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "page", Property: "input.pageNumber"}, + {Name: "limit", Property: "input.pageSize"}, + {Name: "status", Property: "input.status"}, + {Name: "tag", Property: "input.tag"}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + diplomaGetCmd := &cobra.Command{ + Use: "get", + Short: "查询奖状详情", + Long: `按奖状 ID 查询奖状详情信息。 +--diploma-id 为奖状 ID(必填),--user-name 为可选参数。`, + Example: ` dws edu-app diploma get --diploma-id 12345 + dws edu-app diploma get --diploma-id 12345 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + diplomaID, err := eduAppRequiredIntFlag(cmd, "diploma-id") + if err != nil { + return err + } + input := map[string]any{ + "diplomaId": diplomaID, + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "get_diploma", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(diplomaGetCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "get_diploma", + CanonicalPath: "edu-app.get_diploma", + CLIPath: "edu-app diploma get", + PrimaryCLIPath: "edu-app diploma get", + }, + Description: "查询奖状详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "get_diploma"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按奖状ID查询奖状详情", + UseWhen: []string{"需要查询指定奖状的详情时"}, + AvoidWhen: []string{"查询奖状列表用 diploma list-by-teacher 或 diploma list-by-student"}, + Examples: []string{ + "dws edu-app diploma get --diploma-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "diploma-id", Property: "input.diplomaId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + diplomaStatisticsCmd := &cobra.Command{ + Use: "statistics", + Short: "查询奖状阅读统计", + Long: `按奖状 ID 查询奖状的阅读统计数据(按班级维度)。 +返回每个班级的总人数、已读人数、未读人数。 +--diploma-id 为奖状 ID(必填),--user-name 为可选参数。`, + Example: ` dws edu-app diploma statistics --diploma-id 12345 + dws edu-app diploma statistics --diploma-id 12345 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + diplomaID, err := eduAppRequiredIntFlag(cmd, "diploma-id") + if err != nil { + return err + } + input := map[string]any{ + "diplomaId": diplomaID, + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "query_diploma_statistics", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(diplomaStatisticsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_diploma_statistics", + CanonicalPath: "edu-app.query_diploma_statistics", + CLIPath: "edu-app diploma statistics", + PrimaryCLIPath: "edu-app diploma statistics", + }, + Description: "查询奖状阅读统计", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_diploma_statistics"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按奖状ID查询奖状阅读统计数据", + UseWhen: []string{"需要查询奖状的阅读统计数据时"}, + AvoidWhen: []string{"查询奖状接收详情用 diploma detail"}, + Examples: []string{ + "dws edu-app diploma statistics --diploma-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "diploma-id", Property: "input.diplomaId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + diplomaDetailCmd := &cobra.Command{ + Use: "detail", + Short: "查询奖状接收详情", + Long: `按奖状 ID 查询奖状的接收详情列表。 +返回每个学生的接收状态、接收时间、查看时间等信息。 +--diploma-id 为奖状 ID(必填),--class-id、--user-name 为可选参数。`, + Example: ` dws edu-app diploma detail --diploma-id 12345 + dws edu-app diploma detail --diploma-id 12345 --class-id 67890 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + diplomaID, err := eduAppRequiredIntFlag(cmd, "diploma-id") + if err != nil { + return err + } + input := map[string]any{ + "diplomaId": diplomaID, + } + if v, _ := cmd.Flags().GetString("class-id"); strings.TrimSpace(v) != "" { + input["classId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "query_diploma_detail", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(diplomaDetailCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_diploma_detail", + CanonicalPath: "edu-app.query_diploma_detail", + CLIPath: "edu-app diploma detail", + PrimaryCLIPath: "edu-app diploma detail", + }, + Description: "查询奖状接收详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_diploma_detail"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按奖状ID查询奖状接收详情列表", + UseWhen: []string{"需要查询奖状的接收详情时"}, + AvoidWhen: []string{"查询奖状阅读统计用 diploma statistics;查询学生奖状详情用 diploma student-detail"}, + Examples: []string{ + "dws edu-app diploma detail --diploma-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "diploma-id", Property: "input.diplomaId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId"}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + diplomaListByStudentCmd := &cobra.Command{ + Use: "list-by-student", + Short: "查询学生收到的奖状列表", + Long: `查询指定学生收到的奖状列表,支持分页。 +--student-id 和 --class-id 为必填参数。`, + Example: ` dws edu-app diploma list-by-student --student-id uid1 --class-id 12345 + dws edu-app diploma list-by-student --student-id uid1 --class-id 12345 --page 1 --limit 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + studentID, _ := cmd.Flags().GetString("student-id") + studentID = strings.TrimSpace(studentID) + if studentID == "" { + return fmt.Errorf("--student-id 为必填参数") + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + input := map[string]any{ + "studentId": studentID, + "classId": classID, + } + pageNumber, _ := cmd.Flags().GetInt64("page") + if pageNumber <= 0 { + pageNumber = 1 + } + input["pageNumber"] = pageNumber + pageSize, _ := cmd.Flags().GetInt64("limit") + if pageSize <= 0 { + pageSize = 20 + } + input["pageSize"] = pageSize + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "list_diploma_by_student", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(diplomaListByStudentCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "list_diploma_by_student", + CanonicalPath: "edu-app.list_diploma_by_student", + CLIPath: "edu-app diploma list-by-student", + PrimaryCLIPath: "edu-app diploma list-by-student", + }, + Description: "查询学生收到的奖状列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "list_diploma_by_student"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询学生收到的奖状列表", + UseWhen: []string{"需要查询指定学生收到的奖状列表时"}, + AvoidWhen: []string{"查询老师创建的奖状用 diploma list-by-teacher"}, + Examples: []string{ + "dws edu-app diploma list-by-student --student-id uid1 --class-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "student-id", Property: "input.studentId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "page", Property: "input.pageNumber"}, + {Name: "limit", Property: "input.pageSize"}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + diplomaStudentDetailCmd := &cobra.Command{ + Use: "student-detail", + Short: "查询学生奖状接收详情", + Long: `按奖状 ID 和学生 ID 查询该学生的奖状接收详情。 +--diploma-id、--student-id、--class-id 为必填参数。`, + Example: ` dws edu-app diploma student-detail --diploma-id 12345 --student-id uid1 --class-id 67890 + dws edu-app diploma student-detail --diploma-id 12345 --student-id uid1 --class-id 67890 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + diplomaID, err := eduAppRequiredIntFlag(cmd, "diploma-id") + if err != nil { + return err + } + studentID, _ := cmd.Flags().GetString("student-id") + studentID = strings.TrimSpace(studentID) + if studentID == "" { + return fmt.Errorf("--student-id 为必填参数") + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + input := map[string]any{ + "diplomaId": diplomaID, + "studentId": studentID, + "classId": classID, + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "query_student_diploma_detail", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(diplomaStudentDetailCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_student_diploma_detail", + CanonicalPath: "edu-app.query_student_diploma_detail", + CLIPath: "edu-app diploma student-detail", + PrimaryCLIPath: "edu-app diploma student-detail", + }, + Description: "查询学生奖状接收详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_student_diploma_detail"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按奖状ID和学生ID查询学生奖状接收详情", + UseWhen: []string{"需要查询指定学生的奖状接收详情时"}, + AvoidWhen: []string{"查询奖状接收详情列表用 diploma detail"}, + Examples: []string{ + "dws edu-app diploma student-detail --diploma-id 12345 --student-id uid1 --class-id 67890 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "diploma-id", Property: "input.diplomaId", Required: boolPtr(true)}, + {Name: "student-id", Property: "input.studentId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // homework 子命令组 — 作业 + // ════════════════════════════════════════════════════════════ + + homeworkCmd := &cobra.Command{Use: "homework", Short: "作业管理", RunE: groupRunE} + + homeworkCreateCmd := &cobra.Command{ + Use: "create", + Short: "创建并发布作业", + Long: `创建并发布一份作业。 +--identifier 为幂等标识(必填),建议格式:orgId-staffId-UUID,重复提交同一 identifier 不会重复创建。 +--hw-content 为作业内容(必填),其余参数均为可选。`, + Example: ` dws edu-app homework create --identifier org1-staff1-uuid --hw-content "完成课后练习第三题" + dws edu-app homework create --identifier org1-staff1-uuid --hw-title "数学作业" --hw-content "完成练习册" --class-ids 12345,67890 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + identifier, _ := cmd.Flags().GetString("identifier") + identifier = strings.TrimSpace(identifier) + if identifier == "" { + return fmt.Errorf("--identifier 为必填参数") + } + hwContent, _ := cmd.Flags().GetString("hw-content") + hwContent = strings.TrimSpace(hwContent) + if hwContent == "" { + return fmt.Errorf("--hw-content 为必填参数") + } + input := map[string]any{ + "identifier": identifier, + "hwContent": hwContent, + } + if v, _ := cmd.Flags().GetString("hw-title"); strings.TrimSpace(v) != "" { + input["hwTitle"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("hw-photo"); strings.TrimSpace(v) != "" { + input["hwPhoto"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("hw-media"); strings.TrimSpace(v) != "" { + input["hwMedia"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("hw-video"); strings.TrimSpace(v) != "" { + input["hwVideo"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("class-ids"); strings.TrimSpace(v) != "" { + input["classIds"] = eduAppParseCSV(v) + } + if v, _ := cmd.Flags().GetString("class-names"); strings.TrimSpace(v) != "" { + input["classNames"] = eduAppParseCSV(v) + } + if v, _ := cmd.Flags().GetString("class-selected-students"); strings.TrimSpace(v) != "" { + var obj any + if err := json.Unmarshal([]byte(v), &obj); err != nil { + return fmt.Errorf("--class-selected-students JSON 格式错误: %w", err) + } + input["classSelectedStudents"] = obj + } + if v, _ := cmd.Flags().GetString("feedback"); strings.TrimSpace(v) != "" { + input["feedback"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("hw-deadline"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--hw-deadline 须为整数: %w", err) + } + input["hwDeadline"] = n + } + if v, _ := cmd.Flags().GetString("hw-deadline-open"); strings.TrimSpace(v) != "" { + input["hwDeadlineOpen"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("hw-deadline-setting"); strings.TrimSpace(v) != "" { + input["hwDeadlineSetting"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("submit-types"); strings.TrimSpace(v) != "" { + input["submitTypes"] = eduAppParseCSV(v) + } + if v, _ := cmd.Flags().GetString("hw-type"); strings.TrimSpace(v) != "" { + input["hwType"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("target-role"); strings.TrimSpace(v) != "" { + input["targetRole"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("publish-type"); strings.TrimSpace(v) != "" { + input["publishType"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("biz-code"); strings.TrimSpace(v) != "" { + input["bizCode"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("scheduled-release"); strings.TrimSpace(v) != "" { + input["scheduledRelease"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("task-plan-duration"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--task-plan-duration 须为整数: %w", err) + } + input["taskPlanDuration"] = n + } + if v, _ := cmd.Flags().GetString("attributes"); strings.TrimSpace(v) != "" { + var obj any + if err := json.Unmarshal([]byte(v), &obj); err != nil { + return fmt.Errorf("--attributes JSON 格式错误: %w", err) + } + input["attributes"] = obj + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "create_homework", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkCreateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "create_homework", + CanonicalPath: "edu-app.create_homework", + CLIPath: "edu-app homework create", + PrimaryCLIPath: "edu-app homework create", + }, + Description: "创建并发布作业", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "create_homework"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建并发布一份作业", + UseWhen: []string{"需要创建并发布作业时"}, + AvoidWhen: []string{"删除作业用 homework delete;查询作业用 homework get"}, + Examples: []string{ + "dws edu-app homework create --identifier org1-staff1-uuid --hw-content \"完成课后练习第三题\" --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "identifier", Property: "input.identifier", Required: boolPtr(true)}, + {Name: "hw-content", Property: "input.hwContent", Required: boolPtr(true)}, + {Name: "hw-title", Property: "input.hwTitle"}, + {Name: "hw-photo", Property: "input.hwPhoto"}, + {Name: "hw-media", Property: "input.hwMedia"}, + {Name: "hw-video", Property: "input.hwVideo"}, + {Name: "class-ids", Property: "input.classIds"}, + {Name: "class-names", Property: "input.classNames"}, + {Name: "class-selected-students", Property: "input.classSelectedStudents"}, + {Name: "feedback", Property: "input.feedback"}, + {Name: "hw-deadline", Property: "input.hwDeadline"}, + {Name: "hw-deadline-open", Property: "input.hwDeadlineOpen"}, + {Name: "hw-deadline-setting", Property: "input.hwDeadlineSetting"}, + {Name: "submit-types", Property: "input.submitTypes"}, + {Name: "hw-type", Property: "input.hwType"}, + {Name: "target-role", Property: "input.targetRole"}, + {Name: "publish-type", Property: "input.publishType"}, + {Name: "biz-code", Property: "input.bizCode"}, + {Name: "scheduled-release", Property: "input.scheduledRelease"}, + {Name: "task-plan-duration", Property: "input.taskPlanDuration"}, + {Name: "attributes", Property: "input.attributes"}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + homeworkDeleteCmd := &cobra.Command{ + Use: "delete", + Short: "删除作业", + Long: `删除指定的作业。 +返回删除结果:success 表示是否删除成功。`, + Example: ` dws edu-app homework delete --homework-id 12345 + dws edu-app homework delete --homework-id 12345 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + homeworkID, err := eduAppRequiredIntFlag(cmd, "homework-id") + if err != nil { + return err + } + input := map[string]any{ + "homeworkId": homeworkID, + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "delete_homework", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkDeleteCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "delete_homework", + CanonicalPath: "edu-app.delete_homework", + CLIPath: "edu-app homework delete", + PrimaryCLIPath: "edu-app homework delete", + }, + Description: "删除作业", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "delete_homework"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "删除指定的作业", + UseWhen: []string{"需要删除指定作业时"}, + AvoidWhen: []string{"查询作业详情用 homework get;创建作业用 homework create"}, + Examples: []string{ + "dws edu-app homework delete --homework-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "homework-id", Property: "input.homeworkId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + homeworkSubmitCmd := &cobra.Command{ + Use: "submit", + Short: "提交作业", + Long: `提交指定作业内容。 +--hw-content-detail-id 为作业内容详情 ID(必填),其余参数均为可选。`, + Example: ` dws edu-app homework submit --hw-content-detail-id 12345 + dws edu-app homework submit --hw-content-detail-id 12345 --homework-id 67890 --content "已完成" --photo "https://example.com/img.jpg" -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + hwContentDetailID, err := eduAppRequiredIntFlag(cmd, "hw-content-detail-id") + if err != nil { + return err + } + input := map[string]any{ + "hwContentDetailId": hwContentDetailID, + } + if v, _ := cmd.Flags().GetString("homework-id"); strings.TrimSpace(v) != "" { + n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return fmt.Errorf("--homework-id 须为整数: %w", err) + } + input["homeworkId"] = n + } + if v, _ := cmd.Flags().GetString("student-id"); strings.TrimSpace(v) != "" { + input["studentId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("class-id"); strings.TrimSpace(v) != "" { + input["classId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("content"); strings.TrimSpace(v) != "" { + input["content"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("photo"); strings.TrimSpace(v) != "" { + input["photo"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("media"); strings.TrimSpace(v) != "" { + input["media"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("video"); strings.TrimSpace(v) != "" { + input["video"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "submit_homework", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkSubmitCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "submit_homework", + CanonicalPath: "edu-app.submit_homework", + CLIPath: "edu-app homework submit", + PrimaryCLIPath: "edu-app homework submit", + }, + Description: "提交作业", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "submit_homework"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "提交指定作业内容", + UseWhen: []string{"需要提交作业时"}, + AvoidWhen: []string{"查询作业详情用 homework get;创建作业用 homework create"}, + Examples: []string{ + "dws edu-app homework submit --hw-content-detail-id 12345 --content \"已完成\" --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "hw-content-detail-id", Property: "input.hwContentDetailId", Required: boolPtr(true)}, + {Name: "homework-id", Property: "input.homeworkId"}, + {Name: "student-id", Property: "input.studentId"}, + {Name: "class-id", Property: "input.classId"}, + {Name: "content", Property: "input.content"}, + {Name: "photo", Property: "input.photo"}, + {Name: "media", Property: "input.media"}, + {Name: "video", Property: "input.video"}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + homeworkGetCmd := &cobra.Command{ + Use: "get", + Short: "查询作业详情", + Long: `按作业 ID 查询作业详情。 +--homework-id 为作业 ID(必填),--user-name 为可选参数。`, + Example: ` dws edu-app homework get --homework-id 12345 + dws edu-app homework get --homework-id 12345 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + homeworkID, err := eduAppRequiredIntFlag(cmd, "homework-id") + if err != nil { + return err + } + input := map[string]any{ + "homeworkId": homeworkID, + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "get_homework", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkGetCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "get_homework", + CanonicalPath: "edu-app.get_homework", + CLIPath: "edu-app homework get", + PrimaryCLIPath: "edu-app homework get", + }, + Description: "查询作业详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "get_homework"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按作业ID查询作业详情", + UseWhen: []string{"需要查询指定作业的详情时"}, + AvoidWhen: []string{"查询作业列表用 homework list-by-teacher 或 homework list-by-student"}, + Examples: []string{ + "dws edu-app homework get --homework-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "homework-id", Property: "input.homeworkId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + homeworkClassByHomeworkCmd := &cobra.Command{ + Use: "class-by-homework", + Short: "查询作业的班级提交情况", + Long: `按作业 ID 查询作业的班级列表及学生提交明细。 +--homework-id 为作业 ID(必填),--class-id、--user-name 为可选参数。`, + Example: ` dws edu-app homework class-by-homework --homework-id 12345 + dws edu-app homework class-by-homework --homework-id 12345 --class-id 67890 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + homeworkID, err := eduAppRequiredIntFlag(cmd, "homework-id") + if err != nil { + return err + } + input := map[string]any{ + "homeworkId": homeworkID, + } + if v, _ := cmd.Flags().GetString("class-id"); strings.TrimSpace(v) != "" { + input["classId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "query_class_by_homework", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkClassByHomeworkCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_class_by_homework", + CanonicalPath: "edu-app.query_class_by_homework", + CLIPath: "edu-app homework class-by-homework", + PrimaryCLIPath: "edu-app homework class-by-homework", + }, + Description: "查询作业的班级提交情况", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_class_by_homework"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按作业ID查询作业的班级提交情况", + UseWhen: []string{"需要查询作业的班级列表及学生提交明细时"}, + AvoidWhen: []string{"查询班级作业详情用 homework class-detail"}, + Examples: []string{ + "dws edu-app homework class-by-homework --homework-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "homework-id", Property: "input.homeworkId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId"}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + homeworkClassDetailCmd := &cobra.Command{ + Use: "class-detail", + Short: "查询班级作业详情", + Long: `按作业 ID 和班级 ID 查询班级作业的提交明细。 +--homework-id、--class-id、--user-name 均为必填参数。`, + Example: ` dws edu-app homework class-detail --homework-id 12345 --class-id 67890 --user-name 张三 + dws edu-app homework class-detail --homework-id 12345 --class-id 67890 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + homeworkID, err := eduAppRequiredIntFlag(cmd, "homework-id") + if err != nil { + return err + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + userName, _ := cmd.Flags().GetString("user-name") + userName = strings.TrimSpace(userName) + if userName == "" { + return fmt.Errorf("--user-name 为必填参数") + } + input := map[string]any{ + "homeworkId": homeworkID, + "classId": classID, + "userName": userName, + } + return callMCPToolOnServer("edu-app", "query_class_homework_detail", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkClassDetailCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_class_homework_detail", + CanonicalPath: "edu-app.query_class_homework_detail", + CLIPath: "edu-app homework class-detail", + PrimaryCLIPath: "edu-app homework class-detail", + }, + Description: "查询班级作业详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_class_homework_detail"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按作业ID和班级ID查询班级作业提交明细", + UseWhen: []string{"需要查询班级作业的提交明细时"}, + AvoidWhen: []string{"查询作业班级列表用 homework class-by-homework"}, + Examples: []string{ + "dws edu-app homework class-detail --homework-id 12345 --class-id 67890 --user-name 张三 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "homework-id", Property: "input.homeworkId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName", Required: boolPtr(true)}, + }, + }, + }) + + homeworkSubmitStatisticsCmd := &cobra.Command{ + Use: "submit-statistics", + Short: "查询作业提交统计", + Long: `按作业 ID 和班级 ID 查询作业提交统计(已提交人数、已批改人数)。 +--homework-id、--class-id 为必填参数,--user-name 为可选参数。`, + Example: ` dws edu-app homework submit-statistics --homework-id 12345 --class-id 67890 + dws edu-app homework submit-statistics --homework-id 12345 --class-id 67890 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + homeworkID, err := eduAppRequiredIntFlag(cmd, "homework-id") + if err != nil { + return err + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + input := map[string]any{ + "homeworkId": homeworkID, + "classId": classID, + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "query_submit_statistics", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkSubmitStatisticsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_submit_statistics", + CanonicalPath: "edu-app.query_submit_statistics", + CLIPath: "edu-app homework submit-statistics", + PrimaryCLIPath: "edu-app homework submit-statistics", + }, + Description: "查询作业提交统计", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_submit_statistics"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按作业ID和班级ID查询作业提交统计", + UseWhen: []string{"需要查询作业提交统计时"}, + AvoidWhen: []string{"查询班级作业详情用 homework class-detail"}, + Examples: []string{ + "dws edu-app homework submit-statistics --homework-id 12345 --class-id 67890 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "homework-id", Property: "input.homeworkId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + homeworkListByStudentCmd := &cobra.Command{ + Use: "list-by-student", + Short: "查询学生作业列表", + Long: `按学生 ID 和班级 ID 查询学生收到的作业列表。 +--student-id、--class-id、--user-name 为必填参数,--status、--page、--page-size 为可选参数。`, + Example: ` dws edu-app homework list-by-student --student-id uid1 --class-id 67890 --user-name 张三 + dws edu-app homework list-by-student --student-id uid1 --class-id 67890 --user-name 张三 --status FINISHED --page 1 --page-size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + studentID, _ := cmd.Flags().GetString("student-id") + studentID = strings.TrimSpace(studentID) + if studentID == "" { + return fmt.Errorf("--student-id 为必填参数") + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + userName, _ := cmd.Flags().GetString("user-name") + userName = strings.TrimSpace(userName) + if userName == "" { + return fmt.Errorf("--user-name 为必填参数") + } + input := map[string]any{ + "studentId": studentID, + "classId": classID, + "userName": userName, + } + if v, _ := cmd.Flags().GetString("status"); strings.TrimSpace(v) != "" { + input["status"] = strings.TrimSpace(v) + } + pageNumber, _ := cmd.Flags().GetInt64("page") + if pageNumber > 0 { + input["pageNumber"] = pageNumber + } else { + input["pageNumber"] = 1 + } + pageSize, _ := cmd.Flags().GetInt64("page-size") + if pageSize > 0 { + input["pageSize"] = pageSize + } else { + input["pageSize"] = 20 + } + return callMCPToolOnServer("edu-app", "list_homework_by_student", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkListByStudentCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "list_homework_by_student", + CanonicalPath: "edu-app.list_homework_by_student", + CLIPath: "edu-app homework list-by-student", + PrimaryCLIPath: "edu-app homework list-by-student", + }, + Description: "查询学生作业列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "list_homework_by_student"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按学生ID和班级ID查询学生收到的作业列表", + UseWhen: []string{"需要查询学生收到的作业列表时"}, + AvoidWhen: []string{"查询老师作业列表用 homework list-by-teacher"}, + Examples: []string{ + "dws edu-app homework list-by-student --student-id uid1 --class-id 67890 --user-name 张三 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "student-id", Property: "input.studentId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName", Required: boolPtr(true)}, + {Name: "status", Property: "input.status"}, + {Name: "page", Property: "input.pageNumber"}, + {Name: "page-size", Property: "input.pageSize"}, + }, + }, + }) + + homeworkStudentDetailCmd := &cobra.Command{ + Use: "student-detail", + Short: "查询学生作业详情", + Long: `按作业 ID、学生 ID 和班级 ID 查询单个学生的作业提交明细。 +--homework-id、--student-id、--class-id 为必填参数,--user-name 为可选参数。`, + Example: ` dws edu-app homework student-detail --homework-id 12345 --student-id uid1 --class-id 67890 + dws edu-app homework student-detail --homework-id 12345 --student-id uid1 --class-id 67890 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + homeworkID, err := eduAppRequiredIntFlag(cmd, "homework-id") + if err != nil { + return err + } + studentID, _ := cmd.Flags().GetString("student-id") + studentID = strings.TrimSpace(studentID) + if studentID == "" { + return fmt.Errorf("--student-id 为必填参数") + } + classID, _ := cmd.Flags().GetString("class-id") + classID = strings.TrimSpace(classID) + if classID == "" { + return fmt.Errorf("--class-id 为必填参数") + } + input := map[string]any{ + "homeworkId": homeworkID, + "studentId": studentID, + "classId": classID, + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "query_student_homework_detail", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkStudentDetailCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "query_student_homework_detail", + CanonicalPath: "edu-app.query_student_homework_detail", + CLIPath: "edu-app homework student-detail", + PrimaryCLIPath: "edu-app homework student-detail", + }, + Description: "查询学生作业详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "query_student_homework_detail"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "按作业ID和学生ID查询学生作业提交明细", + UseWhen: []string{"需要查询单个学生的作业提交明细时"}, + AvoidWhen: []string{"查询班级作业详情用 homework class-detail"}, + Examples: []string{ + "dws edu-app homework student-detail --homework-id 12345 --student-id uid1 --class-id 67890 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "homework-id", Property: "input.homeworkId", Required: boolPtr(true)}, + {Name: "student-id", Property: "input.studentId", Required: boolPtr(true)}, + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + homeworkListByTeacherCmd := &cobra.Command{ + Use: "list-by-teacher", + Short: "查询老师作业列表", + Long: `查询老师发布的家校作业列表,支持分页和筛选。 +所有参数均为可选,--class-id 按班级筛选,--type 按作业类型筛选,--status 按作业状态筛选。`, + Example: ` dws edu-app homework list-by-teacher + dws edu-app homework list-by-teacher --class-id 67890 --type HOMEWORK --status FINISHED --page 1 --page-size 20 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + if v, _ := cmd.Flags().GetString("class-id"); strings.TrimSpace(v) != "" { + input["classId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("type"); strings.TrimSpace(v) != "" { + input["type"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("status"); strings.TrimSpace(v) != "" { + input["status"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + pageNumber, _ := cmd.Flags().GetInt64("page") + if pageNumber > 0 { + input["pageNumber"] = pageNumber + } else { + input["pageNumber"] = 1 + } + pageSize, _ := cmd.Flags().GetInt64("page-size") + if pageSize > 0 { + input["pageSize"] = pageSize + } else { + input["pageSize"] = 20 + } + return callMCPToolOnServer("edu-app", "list_homework_by_teacher", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkListByTeacherCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "list_homework_by_teacher", + CanonicalPath: "edu-app.list_homework_by_teacher", + CLIPath: "edu-app homework list-by-teacher", + PrimaryCLIPath: "edu-app homework list-by-teacher", + }, + Description: "查询老师作业列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "list_homework_by_teacher"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询老师发布的家校作业列表", + UseWhen: []string{"需要查询老师发布的作业列表时"}, + AvoidWhen: []string{"查询学生作业列表用 homework list-by-student"}, + Examples: []string{ + "dws edu-app homework list-by-teacher --class-id 67890 --page 1 --page-size 20 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-id", Property: "input.classId"}, + {Name: "type", Property: "input.type"}, + {Name: "status", Property: "input.status"}, + {Name: "user-name", Property: "input.userName"}, + {Name: "page", Property: "input.pageNumber"}, + {Name: "page-size", Property: "input.pageSize"}, + }, + }, + }) + + homeworkCreateCommentCmd := &cobra.Command{ + Use: "create-comment", + Short: "创建作业评语", + Long: `为指定作业创建评语。 +--comment、--hw-content-detail-id 为必填参数,--homework-id、--student-id、--photo、--video、--media、--user-name 为可选参数。`, + Example: ` dws edu-app homework create-comment --comment "做得很好" --hw-content-detail-id 12345 + dws edu-app homework create-comment --comment "继续努力" --hw-content-detail-id 12345 --homework-id 67890 --student-id uid1 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + comment, _ := cmd.Flags().GetString("comment") + comment = strings.TrimSpace(comment) + if comment == "" { + return fmt.Errorf("--comment 为必填参数") + } + hwContentDetailID, err := eduAppRequiredIntFlag(cmd, "hw-content-detail-id") + if err != nil { + return err + } + input := map[string]any{ + "comment": comment, + "hwContentDetailId": hwContentDetailID, + } + if cmd.Flags().Changed("homework-id") { + v, _ := cmd.Flags().GetInt64("homework-id") + input["homeworkId"] = v + } + if v, _ := cmd.Flags().GetString("student-id"); strings.TrimSpace(v) != "" { + input["studentId"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("photo"); strings.TrimSpace(v) != "" { + input["photo"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("video"); strings.TrimSpace(v) != "" { + input["video"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("media"); strings.TrimSpace(v) != "" { + input["media"] = strings.TrimSpace(v) + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "create_comment", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(homeworkCreateCommentCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "create_comment", + CanonicalPath: "edu-app.create_comment", + CLIPath: "edu-app homework create-comment", + PrimaryCLIPath: "edu-app homework create-comment", + }, + Description: "创建作业评语", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "create_comment"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "为指定作业创建评语", + UseWhen: []string{"需要为作业创建评语时"}, + AvoidWhen: []string{"提交作业用 homework submit;查询作业用 homework get"}, + Examples: []string{ + "dws edu-app homework create-comment --comment \"做得很好\" --hw-content-detail-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "comment", Property: "input.comment", Required: boolPtr(true)}, + {Name: "hw-content-detail-id", Property: "input.hwContentDetailId", Required: boolPtr(true)}, + {Name: "homework-id", Property: "input.homeworkId"}, + {Name: "student-id", Property: "input.studentId"}, + {Name: "photo", Property: "input.photo"}, + {Name: "video", Property: "input.video"}, + {Name: "media", Property: "input.media"}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + diplomaDeleteCmd := &cobra.Command{ + Use: "delete", + Short: "删除奖状", + Long: `删除指定的奖状。 +返回删除结果:success 表示是否删除成功。`, + Example: ` dws edu-app diploma delete --diploma-id 12345 + dws edu-app diploma delete --diploma-id 12345 --user-name 张三 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + diplomaID, err := eduAppRequiredIntFlag(cmd, "diploma-id") + if err != nil { + return err + } + input := map[string]any{ + "diplomaId": diplomaID, + } + if v, _ := cmd.Flags().GetString("user-name"); strings.TrimSpace(v) != "" { + input["userName"] = strings.TrimSpace(v) + } + return callMCPToolOnServer("edu-app", "delete_diploma", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(diplomaDeleteCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-app", + Name: "delete_diploma", + CanonicalPath: "edu-app.delete_diploma", + CLIPath: "edu-app diploma delete", + PrimaryCLIPath: "edu-app diploma delete", + }, + Description: "删除奖状", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-app", RPCName: "delete_diploma"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "删除指定的奖状", + UseWhen: []string{"需要删除指定奖状时"}, + AvoidWhen: []string{"查询奖状详情用 diploma get;创建奖状用 diploma create"}, + Examples: []string{ + "dws edu-app diploma delete --diploma-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "diploma-id", Property: "input.diplomaId", Required: boolPtr(true)}, + {Name: "user-name", Property: "input.userName"}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // flags + 构建命令树 + // ════════════════════════════════════════════════════════════ + + // message flags + messageSummaryListCmd.Flags().String("class-id", "", "班级 ID(必填)") + messageSummaryListCmd.Flags().String("cid", "", "群会话 ID(必填)") + messageSummaryListCmd.Flags().String("target-role", "", "用户角色:guardian / student(必填)") + messageSummaryListCmd.Flags().String("status", "", "任务状态:0-未处理 1-已处理(必填)") + + // task flags + taskPublishListCmd.Flags().Int64("cursor", 0, "分页游标,首次不传") + taskPublishListCmd.Flags().Int64("limit", 0, "每页条数") + taskPublishListCmd.Flags().Bool("need-statistic", false, "是否返回任务完成统计") + taskPublishListCmd.Flags().String("task-sources", "", "任务来源,逗号分隔(EDU_HOMEWORK/EDU_CARD/EDU_NOTICE/EDU_SR/EDU_DIPLOMA)") + + taskAllListCmd.Flags().String("biz-id", "", "班级 ID(必填)") + taskAllListCmd.Flags().Int64("cursor", 0, "分页游标,首次不传") + taskAllListCmd.Flags().Int64("limit", 0, "每页条数") + taskAllListCmd.Flags().Bool("need-statistic", false, "是否返回任务完成统计") + taskAllListCmd.Flags().String("task-sources", "", "任务来源,逗号分隔") + + taskStudentListCmd.Flags().String("students", "", "学生列表 JSON 数组(必填)") + taskStudentListCmd.Flags().Bool("query-all", false, "是否查询全部(含已完成),默认 false") + taskStudentListCmd.Flags().String("cursor", "", "分页游标") + taskStudentListCmd.Flags().String("limit", "", "每页条数") + taskStudentListCmd.Flags().String("task-sources", "", "任务来源,逗号分隔") + + // report flags + reportGetCmd.Flags().String("ids", "", "成绩单 ID 列表,逗号分隔(必填)") + + reportByTeacherCmd.Flags().Int64("page", 1, "页码,从 1 开始") + reportByTeacherCmd.Flags().Int64("limit", 20, "每页大小") + reportByTeacherCmd.Flags().Int64("status", 0, "成绩单状态:0-未发布 1-已发布") + + reportByClassCmd.Flags().String("report-id", "", "成绩单 ID(必填)") + reportByClassCmd.Flags().String("class-id", "", "班级 ID(必填)") + reportByClassCmd.Flags().String("student-ids", "", "学生 userId 列表,逗号分隔(可选)") + + reportByStudentListCmd.Flags().String("class-id", "", "班级 ID(必填)") + reportByStudentListCmd.Flags().String("student-id", "", "学生 userId(必填)") + reportByStudentListCmd.Flags().Int64("page", 1, "页码,从 1 开始") + reportByStudentListCmd.Flags().Int64("limit", 20, "每页大小") + + reportByStudentDetailCmd.Flags().String("report-id", "", "成绩单 ID(必填)") + reportByStudentDetailCmd.Flags().String("student-id", "", "学生 userId(必填)") + reportByStudentDetailCmd.Flags().String("class-id", "", "班级 ID(必填)") + + // notice flags + noticeConfirmCmd.Flags().String("notice-id", "", "通知 ID(必填)") + noticeConfirmCmd.Flags().String("student-id", "", "学生 userId(必填)") + noticeConfirmCmd.Flags().String("device-id", "", "设备 ID(可选)") + noticeConfirmCmd.Flags().String("parent-name", "", "确认操作人名字(可选)") + noticeConfirmCmd.Flags().Bool("update-sign", false, "是否更新签名(可选)") + + noticeCreateCmd.Flags().String("identifer", "", "幂等字段,建议 orgId-staffId-UUID(必填)") + noticeCreateCmd.Flags().String("content", "", "通知内容(必填)") + noticeCreateCmd.Flags().String("title", "", "通知标题(可选)") + noticeCreateCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(可选)") + noticeCreateCmd.Flags().String("class-names", "", "班级名称列表,逗号分隔(可选)") + noticeCreateCmd.Flags().String("class-selected-students", "", "班级被选中学生 JSON 对象(可选)") + noticeCreateCmd.Flags().String("type", "", "类型(可选)") + noticeCreateCmd.Flags().String("scope", "", "范围(可选)") + noticeCreateCmd.Flags().String("target-role", "", "目标角色(可选)") + noticeCreateCmd.Flags().String("is-signed", "", "是否签收(可选)") + noticeCreateCmd.Flags().String("photo", "", "图片 URL(可选)") + noticeCreateCmd.Flags().String("media", "", "媒体 URL(可选)") + noticeCreateCmd.Flags().String("audio", "", "音频 URL(可选)") + noticeCreateCmd.Flags().Bool("send-ding", false, "是否发送钉钉(可选)") + noticeCreateCmd.Flags().String("scheduled-release", "", "定时发送(可选)") + noticeCreateCmd.Flags().Int64("notice-deadline", 0, "通知截止时间戳(可选)") + noticeCreateCmd.Flags().String("notice-deadline-open", "", "是否开启通知截止时间(可选)") + noticeCreateCmd.Flags().String("notice-deadline-setting", "", "通知截止时间设置(可选)") + noticeCreateCmd.Flags().String("attributes", "", "扩展属性 JSON 对象(可选)") + noticeCreateCmd.Flags().String("user-name", "", "用户名(可选)") + + noticeDeleteCmd.Flags().String("notice-id", "", "通知 ID(必填)") + noticeDeleteCmd.Flags().String("user-name", "", "用户名(可选)") + + noticeListByTeacherCmd.Flags().String("class-id", "", "班级 ID(可选)") + noticeListByTeacherCmd.Flags().String("type", "", "通知类型(可选)") + noticeListByTeacherCmd.Flags().String("status", "", "通知状态筛选(可选)") + noticeListByTeacherCmd.Flags().String("user-name", "", "用户名(可选)") + noticeListByTeacherCmd.Flags().Int64("page", 1, "页码,从 1 开始") + noticeListByTeacherCmd.Flags().Int64("page-size", 20, "每页大小") + + noticeGetCmd.Flags().String("notice-id", "", "通知 ID(必填)") + noticeGetCmd.Flags().String("user-name", "", "用户名(可选)") + + noticeConfirmStatusCmd.Flags().String("notice-id", "", "通知 ID(必填)") + noticeConfirmStatusCmd.Flags().String("class-id", "", "班级 ID(必填)") + noticeConfirmStatusCmd.Flags().String("status", "", "确认状态筛选(可选)") + noticeConfirmStatusCmd.Flags().String("user-name", "", "用户名(可选)") + noticeConfirmStatusCmd.Flags().Int64("page", 1, "页码,从 1 开始") + noticeConfirmStatusCmd.Flags().Int64("page-size", 20, "每页大小") + + noticeListByStudentCmd.Flags().String("student-id", "", "学生 ID(必填)") + noticeListByStudentCmd.Flags().String("class-id", "", "班级 ID(必填)") + noticeListByStudentCmd.Flags().String("status", "", "通知状态筛选(可选)") + noticeListByStudentCmd.Flags().String("user-name", "", "用户名(可选)") + noticeListByStudentCmd.Flags().Int64("page", 1, "页码,从 1 开始") + noticeListByStudentCmd.Flags().Int64("page-size", 20, "每页大小") + + // circle flags + circlePostsCmd.Flags().String("class-id", "", "班级 ID(必填)") + circlePostsCmd.Flags().String("student-id", "", "学生 userId(必填)") + circlePostsCmd.Flags().String("target-role", "", "用户角色:guardian(家长)/ student(学生)(必填)") + + // card flags + cardUpdateCmd.Flags().String("card-id", "", "打卡卡片 ID(必填)") + cardUpdateCmd.Flags().String("identifier", "", "幂等标识,建议 orgId-staffId-UUID(必填)") + cardUpdateCmd.Flags().String("title", "", "修改后的标题(与 --content 至少传一个)") + cardUpdateCmd.Flags().String("content", "", "修改后的内容(与 --title 至少传一个)") + cardUpdateCmd.Flags().Bool("should-send-update-msg", false, "是否发送更新通知消息,默认 false") + + cardEndCmd.Flags().String("card-id", "", "打卡卡片 ID(必填)") + + cardListCmd.Flags().String("status", "", "打卡状态:FINISH-已完结 UNFINISH-进行中(必填)") + cardListCmd.Flags().Int64("class-id", 0, "班级 ID(可选)") + cardListCmd.Flags().Int64("page", 1, "页码,从 1 开始") + cardListCmd.Flags().Int64("limit", 10, "每页大小") + + cardUserStatisticCmd.Flags().String("class-id", "", "班级 ID(必填)") + cardUserStatisticCmd.Flags().String("task-code", "", "任务 code(必填)") + cardUserStatisticCmd.Flags().String("card-id", "", "打卡卡片 ID(必填)") + cardUserStatisticCmd.Flags().Bool("finish", false, "是否查询已完成人员,默认 false(查未完成)") + cardUserStatisticCmd.Flags().Int64("page", 1, "页码,从 1 开始") + cardUserStatisticCmd.Flags().Int64("limit", 10, "每页大小") + + cardFinishInfoCmd.Flags().String("card-id", "", "打卡卡片 ID(必填)") + cardFinishInfoCmd.Flags().String("card-biz-id", "", "打卡业务 ID(必填)") + cardFinishInfoCmd.Flags().String("target-role", "", "目标角色:teacher / guardian / student / headmaster(可选,未传按 uid 真实身份自动推断)") + cardFinishInfoCmd.Flags().String("student-id", "", "学生 ID(可选,targetRole 为 guardian 时用于指定查看某个孩子的进度)") + + // diploma flags + diplomaCreateCmd.Flags().String("identifier", "", "幂等标识,建议 orgId-staffId-UUID(必填)") + diplomaCreateCmd.Flags().String("content", "", "奖状内容(必填)") + diplomaCreateCmd.Flags().String("user-name", "", "获奖人用户名(必填)") + diplomaCreateCmd.Flags().String("title", "", "奖状标题(可选)") + diplomaCreateCmd.Flags().String("unit-name", "", "颁发单位名称(可选)") + diplomaCreateCmd.Flags().String("tag", "", "奖状标签(可选)") + diplomaCreateCmd.Flags().String("photo", "", "奖状图片 URL(可选)") + diplomaCreateCmd.Flags().String("publish-time", "", "颁发时间(可选)") + diplomaCreateCmd.Flags().String("biz-code", "", "业务编码(可选)") + diplomaCreateCmd.Flags().String("biz-category", "", "业务种类(可选)") + diplomaCreateCmd.Flags().String("msg-type", "", "消息类型(可选)") + diplomaCreateCmd.Flags().String("template-url", "", "模板 URL(可选)") + diplomaCreateCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(可选)") + diplomaCreateCmd.Flags().String("select-class", "", "选择的班级及学生 JSON 数组(可选)") + diplomaCreateCmd.Flags().String("attributes", "", "扩展属性 JSON 对象(可选)") + + diplomaReadCmd.Flags().String("diploma-id", "", "奖状 ID(必填)") + diplomaReadCmd.Flags().String("class-id", "", "班级 ID(可选)") + diplomaReadCmd.Flags().String("student-id", "", "学生 ID(可选)") + diplomaReadCmd.Flags().String("user-name", "", "用户名(可选)") + + diplomaListByTeacherCmd.Flags().Int64("page", 1, "页码,从 1 开始") + diplomaListByTeacherCmd.Flags().Int64("limit", 20, "每页大小") + diplomaListByTeacherCmd.Flags().String("status", "", "奖状状态筛选(可选)") + diplomaListByTeacherCmd.Flags().String("tag", "", "标签筛选(可选)") + diplomaListByTeacherCmd.Flags().String("user-name", "", "获奖人用户名筛选(可选)") + + diplomaGetCmd.Flags().String("diploma-id", "", "奖状 ID(必填)") + diplomaGetCmd.Flags().String("user-name", "", "用户名(可选)") + + diplomaStatisticsCmd.Flags().String("diploma-id", "", "奖状 ID(必填)") + diplomaStatisticsCmd.Flags().String("user-name", "", "用户名(可选)") + + diplomaDetailCmd.Flags().String("diploma-id", "", "奖状 ID(必填)") + diplomaDetailCmd.Flags().String("class-id", "", "班级 ID(可选)") + diplomaDetailCmd.Flags().String("user-name", "", "用户名(可选)") + + diplomaListByStudentCmd.Flags().String("student-id", "", "学生 ID(必填)") + diplomaListByStudentCmd.Flags().String("class-id", "", "班级 ID(必填)") + diplomaListByStudentCmd.Flags().Int64("page", 1, "页码,从 1 开始") + diplomaListByStudentCmd.Flags().Int64("limit", 20, "每页大小") + diplomaListByStudentCmd.Flags().String("user-name", "", "用户名(可选)") + + diplomaStudentDetailCmd.Flags().String("diploma-id", "", "奖状 ID(必填)") + diplomaStudentDetailCmd.Flags().String("student-id", "", "学生 ID(必填)") + diplomaStudentDetailCmd.Flags().String("class-id", "", "班级 ID(必填)") + diplomaStudentDetailCmd.Flags().String("user-name", "", "用户名(可选)") + + homeworkCreateCmd.Flags().String("identifier", "", "幂等标识,建议 orgId-staffId-UUID(必填)") + homeworkCreateCmd.Flags().String("hw-content", "", "作业内容(必填)") + homeworkCreateCmd.Flags().String("hw-title", "", "作业标题(可选)") + homeworkCreateCmd.Flags().String("hw-photo", "", "作业图片 URL(可选)") + homeworkCreateCmd.Flags().String("hw-media", "", "作业视频 URL(可选)") + homeworkCreateCmd.Flags().String("hw-video", "", "作业录音 URL(可选)") + homeworkCreateCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(可选)") + homeworkCreateCmd.Flags().String("class-names", "", "班级名称列表,逗号分隔(可选)") + homeworkCreateCmd.Flags().String("class-selected-students", "", "班级被选中学生 JSON 对象(可选)") + homeworkCreateCmd.Flags().String("feedback", "", "反馈(可选)") + homeworkCreateCmd.Flags().String("hw-deadline", "", "作业截止时间戳(可选)") + homeworkCreateCmd.Flags().String("hw-deadline-open", "", "是否开启作业截止时间(可选)") + homeworkCreateCmd.Flags().String("hw-deadline-setting", "", "作业截止时间设置(可选)") + homeworkCreateCmd.Flags().String("submit-types", "", "提交类型列表,逗号分隔(可选)") + homeworkCreateCmd.Flags().String("hw-type", "", "作业类型(可选)") + homeworkCreateCmd.Flags().String("target-role", "", "目标角色(可选)") + homeworkCreateCmd.Flags().String("publish-type", "", "发布类型(可选)") + homeworkCreateCmd.Flags().String("biz-code", "", "业务代码(可选)") + homeworkCreateCmd.Flags().String("scheduled-release", "", "定时发送(可选)") + homeworkCreateCmd.Flags().String("task-plan-duration", "", "计划任务时长(可选)") + homeworkCreateCmd.Flags().String("attributes", "", "扩展属性 JSON 对象(可选)") + homeworkCreateCmd.Flags().String("user-name", "", "用户名(可选)") + + homeworkDeleteCmd.Flags().String("homework-id", "", "作业 ID(必填)") + homeworkDeleteCmd.Flags().String("user-name", "", "用户名(可选)") + + homeworkSubmitCmd.Flags().String("hw-content-detail-id", "", "作业内容详情 ID(必填)") + homeworkSubmitCmd.Flags().String("homework-id", "", "作业 ID(可选)") + homeworkSubmitCmd.Flags().String("student-id", "", "学生 ID(可选)") + homeworkSubmitCmd.Flags().String("class-id", "", "班级 ID(可选)") + homeworkSubmitCmd.Flags().String("content", "", "提交内容(可选)") + homeworkSubmitCmd.Flags().String("photo", "", "提交图片 URL(可选)") + homeworkSubmitCmd.Flags().String("media", "", "提交视频 URL(可选)") + homeworkSubmitCmd.Flags().String("video", "", "提交录音 URL(可选)") + homeworkSubmitCmd.Flags().String("user-name", "", "用户名(可选)") + + homeworkGetCmd.Flags().String("homework-id", "", "作业 ID(必填)") + homeworkGetCmd.Flags().String("user-name", "", "用户名(可选)") + + homeworkClassByHomeworkCmd.Flags().String("homework-id", "", "作业 ID(必填)") + homeworkClassByHomeworkCmd.Flags().String("class-id", "", "班级 ID(可选)") + homeworkClassByHomeworkCmd.Flags().String("user-name", "", "用户名(可选)") + + homeworkClassDetailCmd.Flags().String("homework-id", "", "作业 ID(必填)") + homeworkClassDetailCmd.Flags().String("class-id", "", "班级 ID(必填)") + homeworkClassDetailCmd.Flags().String("user-name", "", "用户名(必填)") + + homeworkSubmitStatisticsCmd.Flags().String("homework-id", "", "作业 ID(必填)") + homeworkSubmitStatisticsCmd.Flags().String("class-id", "", "班级 ID(必填)") + homeworkSubmitStatisticsCmd.Flags().String("user-name", "", "用户名(可选)") + + homeworkListByStudentCmd.Flags().String("student-id", "", "学生 ID(必填)") + homeworkListByStudentCmd.Flags().String("class-id", "", "班级 ID(必填)") + homeworkListByStudentCmd.Flags().String("user-name", "", "用户名(必填)") + homeworkListByStudentCmd.Flags().String("status", "", "提交状态筛选(可选)") + homeworkListByStudentCmd.Flags().Int64("page", 1, "页码,从 1 开始") + homeworkListByStudentCmd.Flags().Int64("page-size", 20, "每页大小") + + homeworkStudentDetailCmd.Flags().String("homework-id", "", "作业 ID(必填)") + homeworkStudentDetailCmd.Flags().String("student-id", "", "学生 ID(必填)") + homeworkStudentDetailCmd.Flags().String("class-id", "", "班级 ID(必填)") + homeworkStudentDetailCmd.Flags().String("user-name", "", "用户名(可选)") + + homeworkListByTeacherCmd.Flags().String("class-id", "", "班级 ID(可选)") + homeworkListByTeacherCmd.Flags().String("type", "", "作业类型(可选)") + homeworkListByTeacherCmd.Flags().String("status", "", "作业状态筛选(可选)") + homeworkListByTeacherCmd.Flags().String("user-name", "", "用户名(可选)") + homeworkListByTeacherCmd.Flags().Int64("page", 1, "页码,从 1 开始") + homeworkListByTeacherCmd.Flags().Int64("page-size", 20, "每页大小") + + homeworkCreateCommentCmd.Flags().String("comment", "", "评语文字内容(必填)") + homeworkCreateCommentCmd.Flags().String("hw-content-detail-id", "", "作业内容详情 ID(必填)") + homeworkCreateCommentCmd.Flags().Int64("homework-id", 0, "作业 ID(可选)") + homeworkCreateCommentCmd.Flags().String("student-id", "", "学生 ID(可选)") + homeworkCreateCommentCmd.Flags().String("photo", "", "评语图片 URL(可选)") + homeworkCreateCommentCmd.Flags().String("video", "", "评语录音 URL(可选)") + homeworkCreateCommentCmd.Flags().String("media", "", "评语视频 URL(可选)") + homeworkCreateCommentCmd.Flags().String("user-name", "", "用户名(可选)") + + diplomaDeleteCmd.Flags().String("diploma-id", "", "奖状 ID(必填)") + diplomaDeleteCmd.Flags().String("user-name", "", "用户名(可选)") + + messageCmd.AddCommand(messageSummaryListCmd) + taskCmd.AddCommand(taskPublishListCmd, taskAllListCmd, taskStudentListCmd) + reportCmd.AddCommand(reportGetCmd, reportByTeacherCmd, reportByClassCmd, reportByStudentListCmd, reportByStudentDetailCmd) + noticeCmd.AddCommand(noticeConfirmCmd, noticeCreateCmd, noticeDeleteCmd, noticeListByTeacherCmd, noticeGetCmd, noticeConfirmStatusCmd, noticeListByStudentCmd) + circleCmd.AddCommand(circlePostsCmd) + cardCmd.AddCommand(cardUpdateCmd, cardEndCmd, cardListCmd, cardUserStatisticCmd, cardFinishInfoCmd) + homeworkCmd.AddCommand(homeworkCreateCmd, homeworkDeleteCmd, homeworkSubmitCmd, homeworkGetCmd, homeworkClassByHomeworkCmd, homeworkClassDetailCmd, homeworkSubmitStatisticsCmd, homeworkListByStudentCmd, homeworkStudentDetailCmd, homeworkListByTeacherCmd, homeworkCreateCommentCmd) + diplomaCmd.AddCommand(diplomaCreateCmd, diplomaReadCmd, diplomaListByTeacherCmd, diplomaGetCmd, diplomaStatisticsCmd, diplomaDetailCmd, diplomaListByStudentCmd, diplomaStudentDetailCmd, diplomaDeleteCmd) + + root.AddCommand(messageCmd, taskCmd, reportCmd, noticeCmd, circleCmd, cardCmd, diplomaCmd, homeworkCmd) + + return root +} + +// eduAppRequiredIntFlag extracts a required integer flag, returning an error +// if the flag is empty or not a valid integer. +func eduAppRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) { + v, _ := cmd.Flags().GetString(name) + v = strings.TrimSpace(v) + if v == "" { + return 0, fmt.Errorf("--%s 为必填参数", name) + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("--%s 须为整数: %w", name, err) + } + return n, nil +} + +// eduAppParseCSV splits a comma-separated string into trimmed non-empty values. +func eduAppParseCSV(raw string) []string { + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + v := strings.TrimSpace(p) + if v != "" { + result = append(result, v) + } + } + return result +} + +// eduAppParseIntCSV splits a comma-separated string into int64 values. +func eduAppParseIntCSV(raw string) ([]int64, error) { + parts := strings.Split(raw, ",") + result := make([]int64, 0, len(parts)) + for _, p := range parts { + v := strings.TrimSpace(p) + if v == "" { + continue + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid integer %q", v) + } + result = append(result, n) + } + return result, nil +} diff --git a/internal/helpers/edu_app_test.go b/internal/helpers/edu_app_test.go new file mode 100644 index 00000000..c12a83e4 --- /dev/null +++ b/internal/helpers/edu_app_test.go @@ -0,0 +1,400 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "io" + "strings" + "testing" +) + +func withEduAppCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{dryRun: true} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func runEduApp(t *testing.T, args ...string) error { + t.Helper() + cmd := newEduAppCommand() + cmd.SetArgs(args) + return cmd.Execute() +} + +// TestEduAppHappyPathsFullFlags exercises each leaf command with every flag +// populated, so all optional-field branches and the dispatch line are covered. +func TestEduAppHappyPathsFullFlags(t *testing.T) { + cases := [][]string{ + {"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "0"}, + + {"task", "publish-list", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_HOMEWORK,EDU_NOTICE"}, + {"task", "all-list", "--biz-id", "1", "--cursor", "5", "--limit", "10", "--need-statistic", "--task-sources", "EDU_CARD"}, + {"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`, "--query-all", "--cursor", "c", "--limit", "10", "--task-sources", "EDU_SR"}, + + {"report", "get", "--ids", "1001,1002"}, + {"report", "by-teacher", "--page", "1", "--limit", "20", "--status", "1"}, + {"report", "by-class", "--report-id", "1001", "--class-id", "12345", "--student-ids", "u1,u2"}, + {"report", "by-student-list", "--class-id", "12345", "--student-id", "u1", "--page", "1", "--limit", "20"}, + {"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"}, + + {"notice", "confirm", "--notice-id", "n1", "--student-id", "u1", "--device-id", "d1", "--parent-name", "张三", "--update-sign"}, + {"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假", "--title", "放假", + "--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`, + "--type", "SCHOOL", "--scope", "ALL", "--target-role", "guardian", "--is-signed", "true", + "--photo", "p", "--media", "m", "--audio", "a", "--send-ding", "--scheduled-release", "2026-07-29", + "--notice-deadline", "100", "--notice-deadline-open", "true", "--notice-deadline-setting", "s", + "--attributes", `{"k":"v"}`, "--user-name", "张三"}, + {"notice", "delete", "--notice-id", "12345", "--user-name", "张三"}, + {"notice", "list-by-teacher", "--class-id", "c", "--type", "SCHOOL", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"}, + {"notice", "get", "--notice-id", "12345", "--user-name", "张三"}, + {"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c", "--status", "CONFIRMED", "--user-name", "u", "--page", "1", "--page-size", "20"}, + {"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"}, + + {"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"}, + + {"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--content", "新内容", "--should-send-update-msg"}, + {"card", "end", "--card-id", "1"}, + {"card", "list", "--status", "UNFINISH", "--class-id", "5", "--page", "1", "--limit", "10"}, + {"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--finish", "--page", "1", "--limit", "20"}, + {"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1", "--target-role", "guardian", "--student-id", "stu1"}, + + {"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "期末三好学生", "--user-name", "张三", + "--title", "三好学生", "--unit-name", "实验小学", "--tag", "三好", "--photo", "p", "--publish-time", "2026-07-29", + "--biz-code", "bc", "--biz-category", "cat", "--msg-type", "mt", "--template-url", "tpl", + "--class-ids", "1,2", "--select-class", `[{"classId":"1"}]`, "--attributes", `{"k":"v"}`}, + {"diploma", "read", "--diploma-id", "1", "--class-id", "c", "--student-id", "u1", "--user-name", "张三"}, + {"diploma", "list-by-teacher", "--page", "1", "--limit", "20", "--status", "PUBLISHED", "--tag", "三好", "--user-name", "u"}, + {"diploma", "get", "--diploma-id", "1", "--user-name", "张三"}, + {"diploma", "statistics", "--diploma-id", "1", "--user-name", "张三"}, + {"diploma", "detail", "--diploma-id", "1", "--class-id", "c", "--user-name", "张三"}, + {"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "1", "--limit", "20", "--user-name", "张三"}, + {"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"}, + {"diploma", "delete", "--diploma-id", "1", "--user-name", "张三"}, + + {"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习", + "--hw-title", "数学作业", "--hw-photo", "p", "--hw-media", "m", "--hw-video", "v", + "--class-ids", "1,2", "--class-names", "一班,二班", "--class-selected-students", `{"1":["u1"]}`, + "--feedback", "fb", "--hw-deadline", "100", "--hw-deadline-open", "true", "--hw-deadline-setting", "s", + "--submit-types", "TEXT,PHOTO", "--hw-type", "HOMEWORK", "--target-role", "guardian", "--publish-type", "NOW", + "--biz-code", "bc", "--scheduled-release", "2026-07-29", "--task-plan-duration", "5", "--attributes", `{"k":"v"}`, "--user-name", "张三"}, + {"homework", "delete", "--homework-id", "1", "--user-name", "张三"}, + {"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "2", "--student-id", "u1", "--class-id", "c", + "--content", "已完成", "--photo", "p", "--media", "m", "--video", "v", "--user-name", "张三"}, + {"homework", "get", "--homework-id", "1", "--user-name", "张三"}, + {"homework", "class-by-homework", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"}, + {"homework", "class-detail", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"}, + {"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c", "--user-name", "张三"}, + {"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--status", "FINISHED", "--page", "1", "--page-size", "20"}, + {"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c", "--user-name", "张三"}, + {"homework", "list-by-teacher", "--class-id", "c", "--type", "HOMEWORK", "--status", "FINISHED", "--user-name", "u", "--page", "1", "--page-size", "20"}, + {"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1", "--homework-id", "2", + "--student-id", "u1", "--photo", "p", "--video", "v", "--media", "m", "--user-name", "张三"}, + } + for _, args := range cases { + t.Run(strings.Join(args, " "), func(t *testing.T) { + withEduAppCaller(t) + if err := runEduApp(t, args...); err != nil { + t.Fatalf("Execute(%v) = %v, want nil", args, err) + } + }) + } +} + +// TestEduAppZeroPagination drives the pagination defaulting branches (page<=0 / +// page-size<=0 / the >0 else arms) that the positive-value happy paths skip. +func TestEduAppZeroPagination(t *testing.T) { + cases := [][]string{ + {"report", "by-teacher", "--page", "0", "--limit", "0"}, + {"report", "by-student-list", "--class-id", "c", "--student-id", "u1", "--page", "0", "--limit", "0"}, + {"notice", "list-by-teacher", "--page", "0", "--page-size", "0"}, + {"notice", "confirm-status", "--notice-id", "1", "--class-id", "c", "--page", "0", "--page-size", "0"}, + {"notice", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--page-size", "0"}, + {"card", "list", "--status", "FINISH", "--page", "0", "--limit", "0"}, + {"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1", "--page", "0", "--limit", "0"}, + {"diploma", "list-by-teacher", "--page", "0", "--limit", "0"}, + {"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c", "--page", "0", "--limit", "0"}, + {"homework", "list-by-student", "--student-id", "u1", "--class-id", "c", "--user-name", "张三", "--page", "0", "--page-size", "0"}, + {"homework", "list-by-teacher", "--page", "0", "--page-size", "0"}, + } + for _, args := range cases { + t.Run(strings.Join(args, " "), func(t *testing.T) { + withEduAppCaller(t) + if err := runEduApp(t, args...); err != nil { + t.Fatalf("Execute(%v) = %v, want nil", args, err) + } + }) + } +} + +func TestEduAppErrorPaths(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"summary-list missing class-id", []string{"message", "summary-list", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "class-id"}, + {"summary-list non-int class-id", []string{"message", "summary-list", "--class-id", "abc", "--cid", "c", "--target-role", "guardian", "--status", "0"}, "整数"}, + {"summary-list missing cid", []string{"message", "summary-list", "--class-id", "1", "--target-role", "guardian", "--status", "0"}, "cid"}, + {"summary-list missing target-role", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--status", "0"}, "target-role"}, + {"summary-list missing status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian"}, "status"}, + {"summary-list non-int status", []string{"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "x"}, "status"}, + + {"all-list missing biz-id", []string{"task", "all-list"}, "biz-id"}, + {"student-list missing students", []string{"task", "student-list"}, "students"}, + {"student-list bad json", []string{"task", "student-list", "--students", "{"}, "JSON"}, + + {"report get missing ids", []string{"report", "get"}, "ids"}, + {"report get non-int ids", []string{"report", "get", "--ids", "abc"}, "整数"}, + {"report by-class missing report-id", []string{"report", "by-class", "--class-id", "c"}, "report-id"}, + {"report by-class non-int report-id", []string{"report", "by-class", "--report-id", "x", "--class-id", "c"}, "整数"}, + {"report by-class missing class-id", []string{"report", "by-class", "--report-id", "1"}, "class-id"}, + {"report by-student-list missing class-id", []string{"report", "by-student-list", "--student-id", "u1"}, "class-id"}, + {"report by-student-list missing student-id", []string{"report", "by-student-list", "--class-id", "c"}, "student-id"}, + {"report by-student-detail missing report-id", []string{"report", "by-student-detail", "--student-id", "u1", "--class-id", "c"}, "report-id"}, + {"report by-student-detail non-int report-id", []string{"report", "by-student-detail", "--report-id", "x", "--student-id", "u1", "--class-id", "c"}, "整数"}, + {"report by-student-detail missing student-id", []string{"report", "by-student-detail", "--report-id", "1", "--class-id", "c"}, "student-id"}, + {"report by-student-detail missing class-id", []string{"report", "by-student-detail", "--report-id", "1", "--student-id", "u1"}, "class-id"}, + + {"notice confirm missing notice-id", []string{"notice", "confirm", "--student-id", "u1"}, "notice-id"}, + {"notice confirm missing student-id", []string{"notice", "confirm", "--notice-id", "n1"}, "student-id"}, + {"notice create missing identifer", []string{"notice", "create", "--content", "c"}, "identifer"}, + {"notice create missing content", []string{"notice", "create", "--identifer", "x"}, "content"}, + {"notice create bad selected-students", []string{"notice", "create", "--identifer", "x", "--content", "c", "--class-selected-students", "{"}, "class-selected-students"}, + {"notice create bad attributes", []string{"notice", "create", "--identifer", "x", "--content", "c", "--attributes", "{"}, "attributes"}, + {"notice delete missing notice-id", []string{"notice", "delete"}, "notice-id"}, + {"notice delete non-int notice-id", []string{"notice", "delete", "--notice-id", "x"}, "整数"}, + {"notice get missing notice-id", []string{"notice", "get"}, "notice-id"}, + {"notice confirm-status missing notice-id", []string{"notice", "confirm-status", "--class-id", "c"}, "notice-id"}, + {"notice confirm-status missing class-id", []string{"notice", "confirm-status", "--notice-id", "1"}, "class-id"}, + {"notice list-by-student missing student-id", []string{"notice", "list-by-student", "--class-id", "c"}, "student-id"}, + {"notice list-by-student missing class-id", []string{"notice", "list-by-student", "--student-id", "u1"}, "class-id"}, + + {"circle posts missing class-id", []string{"circle", "posts", "--student-id", "u1", "--target-role", "guardian"}, "class-id"}, + {"circle posts missing student-id", []string{"circle", "posts", "--class-id", "c", "--target-role", "guardian"}, "student-id"}, + {"circle posts missing target-role", []string{"circle", "posts", "--class-id", "c", "--student-id", "u1"}, "target-role"}, + + {"card update missing card-id", []string{"card", "update", "--identifier", "i", "--title", "t"}, "card-id"}, + {"card update missing identifier", []string{"card", "update", "--card-id", "1", "--title", "t"}, "identifier"}, + {"card update no title no content", []string{"card", "update", "--card-id", "1", "--identifier", "i"}, "至少传一个"}, + {"card end missing card-id", []string{"card", "end"}, "card-id"}, + {"card list missing status", []string{"card", "list"}, "status"}, + {"card list invalid status", []string{"card", "list", "--status", "OTHER"}, "FINISH"}, + {"card user-statistic missing card-id", []string{"card", "user-statistic", "--task-code", "c", "--class-id", "c"}, "card-id"}, + {"card user-statistic missing task-code", []string{"card", "user-statistic", "--card-id", "1", "--class-id", "c"}, "task-code"}, + {"card user-statistic missing class-id", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "c"}, "class-id"}, + {"card finish-info missing card-id", []string{"card", "finish-info", "--card-biz-id", "b"}, "card-id"}, + {"card finish-info missing card-biz-id", []string{"card", "finish-info", "--card-id", "1"}, "card-biz-id"}, + {"card finish-info invalid target-role", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "b", "--target-role", "boss"}, "target-role"}, + + {"diploma create missing identifier", []string{"diploma", "create", "--content", "c", "--user-name", "u"}, "identifier"}, + {"diploma create missing content", []string{"diploma", "create", "--identifier", "i", "--user-name", "u"}, "content"}, + {"diploma create missing user-name", []string{"diploma", "create", "--identifier", "i", "--content", "c"}, "user-name"}, + {"diploma create bad select-class", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--select-class", "{"}, "select-class"}, + {"diploma create bad attributes", []string{"diploma", "create", "--identifier", "i", "--content", "c", "--user-name", "u", "--attributes", "{"}, "attributes"}, + {"diploma read missing diploma-id", []string{"diploma", "read"}, "diploma-id"}, + {"diploma get missing diploma-id", []string{"diploma", "get"}, "diploma-id"}, + {"diploma statistics missing diploma-id", []string{"diploma", "statistics"}, "diploma-id"}, + {"diploma detail missing diploma-id", []string{"diploma", "detail"}, "diploma-id"}, + {"diploma list-by-student missing student-id", []string{"diploma", "list-by-student", "--class-id", "c"}, "student-id"}, + {"diploma list-by-student missing class-id", []string{"diploma", "list-by-student", "--student-id", "u1"}, "class-id"}, + {"diploma student-detail missing diploma-id", []string{"diploma", "student-detail", "--student-id", "u1", "--class-id", "c"}, "diploma-id"}, + {"diploma student-detail missing student-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--class-id", "c"}, "student-id"}, + {"diploma student-detail missing class-id", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1"}, "class-id"}, + {"diploma delete missing diploma-id", []string{"diploma", "delete"}, "diploma-id"}, + + {"homework create missing identifier", []string{"homework", "create", "--hw-content", "c"}, "identifier"}, + {"homework create missing hw-content", []string{"homework", "create", "--identifier", "i"}, "hw-content"}, + {"homework create bad hw-deadline", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--hw-deadline", "x"}, "hw-deadline"}, + {"homework create bad task-plan-duration", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--task-plan-duration", "x"}, "task-plan-duration"}, + {"homework create bad selected-students", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--class-selected-students", "{"}, "class-selected-students"}, + {"homework create bad attributes", []string{"homework", "create", "--identifier", "i", "--hw-content", "c", "--attributes", "{"}, "attributes"}, + {"homework delete missing homework-id", []string{"homework", "delete"}, "homework-id"}, + {"homework submit missing detail-id", []string{"homework", "submit"}, "hw-content-detail-id"}, + {"homework submit bad homework-id", []string{"homework", "submit", "--hw-content-detail-id", "1", "--homework-id", "x"}, "homework-id"}, + {"homework get missing homework-id", []string{"homework", "get"}, "homework-id"}, + {"homework class-by-homework missing homework-id", []string{"homework", "class-by-homework"}, "homework-id"}, + {"homework class-detail missing homework-id", []string{"homework", "class-detail", "--class-id", "c", "--user-name", "u"}, "homework-id"}, + {"homework class-detail missing class-id", []string{"homework", "class-detail", "--homework-id", "1", "--user-name", "u"}, "class-id"}, + {"homework class-detail missing user-name", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c"}, "user-name"}, + {"homework submit-statistics missing homework-id", []string{"homework", "submit-statistics", "--class-id", "c"}, "homework-id"}, + {"homework submit-statistics missing class-id", []string{"homework", "submit-statistics", "--homework-id", "1"}, "class-id"}, + {"homework list-by-student missing student-id", []string{"homework", "list-by-student", "--class-id", "c", "--user-name", "u"}, "student-id"}, + {"homework list-by-student missing class-id", []string{"homework", "list-by-student", "--student-id", "u1", "--user-name", "u"}, "class-id"}, + {"homework list-by-student missing user-name", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c"}, "user-name"}, + {"homework student-detail missing homework-id", []string{"homework", "student-detail", "--student-id", "u1", "--class-id", "c"}, "homework-id"}, + {"homework student-detail missing student-id", []string{"homework", "student-detail", "--homework-id", "1", "--class-id", "c"}, "student-id"}, + {"homework student-detail missing class-id", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1"}, "class-id"}, + {"homework create-comment missing comment", []string{"homework", "create-comment", "--hw-content-detail-id", "1"}, "comment"}, + {"homework create-comment missing detail-id", []string{"homework", "create-comment", "--comment", "cm"}, "hw-content-detail-id"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + withEduAppCaller(t) + err := runEduApp(t, tc.args...) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want) + } + }) + } +} + +func TestEduAppParseHelpers(t *testing.T) { + if got := eduAppParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("eduAppParseCSV = %#v", got) + } + ids, err := eduAppParseIntCSV(" 1 , , 2 ") + if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 { + t.Fatalf("eduAppParseIntCSV = %#v, err = %v", ids, err) + } + if _, err := eduAppParseIntCSV("1,bad"); err == nil { + t.Fatalf("eduAppParseIntCSV invalid = nil error") + } +} + +func withEduAppDispatchCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func TestEduAppDispatch(t *testing.T) { + t.Run("message summary-list dispatches get_ai_message_summary_list", func(t *testing.T) { + caller := withEduAppDispatchCaller(t) + cmd := newEduAppCommand() + cmd.SetArgs([]string{"message", "summary-list", "--class-id", "100", "--cid", "cidxxx", "--target-role", "guardian", "--status", "1"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-app" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-app") + } + if caller.tool != "get_ai_message_summary_list" { + t.Fatalf("tool = %q, want %q", caller.tool, "get_ai_message_summary_list") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"]) + } + if input["classId"] != int64(100) { + t.Fatalf("classId = %v, want 100", input["classId"]) + } + if input["cid"] != "cidxxx" { + t.Fatalf("cid = %v, want %q", input["cid"], "cidxxx") + } + if input["targetRole"] != "guardian" { + t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian") + } + if input["status"] != int64(1) { + t.Fatalf("status = %v, want 1", input["status"]) + } + }) + + t.Run("report get dispatches get_report", func(t *testing.T) { + caller := withEduAppDispatchCaller(t) + cmd := newEduAppCommand() + cmd.SetArgs([]string{"report", "get", "--ids", "1001,1002"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-app" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-app") + } + if caller.tool != "get_report" { + t.Fatalf("tool = %q, want %q", caller.tool, "get_report") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"]) + } + ids, ok := input["schoolReportIdList"].([]int64) + if !ok || len(ids) != 2 || ids[0] != 1001 || ids[1] != 1002 { + t.Fatalf("schoolReportIdList = %v, want [1001 1002]", input["schoolReportIdList"]) + } + }) + + t.Run("circle posts dispatches query_student_circle_posts", func(t *testing.T) { + caller := withEduAppDispatchCaller(t) + cmd := newEduAppCommand() + cmd.SetArgs([]string{"circle", "posts", "--class-id", "12345", "--student-id", "stu1", "--target-role", "guardian"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-app" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-app") + } + if caller.tool != "query_student_circle_posts" { + t.Fatalf("tool = %q, want %q", caller.tool, "query_student_circle_posts") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"]) + } + if input["classId"] != "12345" { + t.Fatalf("classId = %v, want %q", input["classId"], "12345") + } + if input["studentId"] != "stu1" { + t.Fatalf("studentId = %v, want %q", input["studentId"], "stu1") + } + if input["targetRole"] != "guardian" { + t.Fatalf("targetRole = %v, want %q", input["targetRole"], "guardian") + } + }) + + t.Run("card end dispatches end_card", func(t *testing.T) { + caller := withEduAppDispatchCaller(t) + cmd := newEduAppCommand() + cmd.SetArgs([]string{"card", "end", "--card-id", "999"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-app" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-app") + } + if caller.tool != "end_card" { + t.Fatalf("tool = %q, want %q", caller.tool, "end_card") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"]) + } + if input["cardId"] != int64(999) { + t.Fatalf("cardId = %v, want 999", input["cardId"]) + } + }) + + t.Run("card update dispatches update_card", func(t *testing.T) { + caller := withEduAppDispatchCaller(t) + cmd := newEduAppCommand() + cmd.SetArgs([]string{"card", "update", "--card-id", "77", "--identifier", "org1-staff1-uuid", "--title", "新标题", "--should-send-update-msg"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-app" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-app") + } + if caller.tool != "update_card" { + t.Fatalf("tool = %q, want %q", caller.tool, "update_card") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] type = %T, want map[string]any", caller.args["input"]) + } + if input["cardId"] != int64(77) { + t.Fatalf("cardId = %v, want 77", input["cardId"]) + } + if input["identifier"] != "org1-staff1-uuid" { + t.Fatalf("identifier = %v, want %q", input["identifier"], "org1-staff1-uuid") + } + if input["title"] != "新标题" { + t.Fatalf("title = %v, want %q", input["title"], "新标题") + } + if input["shouldSendUpdateMsg"] != true { + t.Fatalf("shouldSendUpdateMsg = %v, want true", input["shouldSendUpdateMsg"]) + } + }) +} diff --git a/internal/helpers/edu_contact.go b/internal/helpers/edu_contact.go new file mode 100644 index 00000000..5548beee --- /dev/null +++ b/internal/helpers/edu_contact.go @@ -0,0 +1,1917 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +// ────────────────────────────────────────────────────────── +// dws edu-contact — 家校通讯录扩展 +// 共 16 个工具,按 school / class / family / teacher 分组 +// ────────────────────────────────────────────────────────── + +func newEduContactCommand() *cobra.Command { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: "edu-contact", + Selection: contract.ProductSelectionDecl{ + AgentSummary: "家校通讯录:学校组织架构/班级/家庭关系/教师信息查询与管理", + UseWhen: []string{ + "用户要查询或管理钉钉家校通讯录,包括学校组织、班级、学生、教师、家长等信息。", + }, + AvoidWhen: []string{ + "家庭群用 edu-familygroup;班级师生群用 edu-group;家校应用/作业/打卡用 edu-app。", + }, + }, + }) + root := &cobra.Command{ + Use: "edu-contact", + Short: "家校通讯录", + Long: `钉钉家校通讯录扩展:查询学校组织/班级/家庭关系/教师信息等。`, + Hidden: true, + RunE: groupRunE, + } + + // ════════════════════════════════════════════════════════════ + // school 子命令组 — 学校/组织级查询 + // ════════════════════════════════════════════════════════════ + + schoolCmd := &cobra.Command{Use: "school", Short: "学校/组织管理", RunE: groupRunE} + + schoolRolesCmd := &cobra.Command{ + Use: "roles", + Short: "查询用户在组织内的身份", + Long: `查询当前用户在指定组织(学校)内拥有的角色身份。 +返回用户拥有的角色列表:teacher、headmaster、student、guardian、superAdmin、admin 等。`, + Example: ` dws edu-contact school roles + dws edu-contact school roles -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("edu-contact", "query_user_roles_in_org", nil) + }, + } + DeclareLeafMetadata(schoolRolesCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "query_user_roles_in_org", + CanonicalPath: "edu-contact.query_user_roles_in_org", + CLIPath: "edu-contact school roles", + PrimaryCLIPath: "edu-contact school roles", + }, + Description: "查询用户在组织内的身份", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "query_user_roles_in_org"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询用户在组织内的身份角色", + UseWhen: []string{"需要查询当前用户在学校组织内拥有哪些角色身份时"}, + AvoidWhen: []string{"查询班级内角色用 class user-role"}, + Examples: []string{ + "dws edu-contact school roles --format json", + }, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + schoolStructureCmd := &cobra.Command{ + Use: "structure", + Short: "查询学校组织架构", + Long: `查询指定学校的完整组织架构树。仅限管理员角色调用。`, + Example: ` dws edu-contact school structure + dws edu-contact school structure -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("edu-contact", "get_school_structure", nil) + }, + } + DeclareLeafMetadata(schoolStructureCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "get_school_structure", + CanonicalPath: "edu-contact.get_school_structure", + CLIPath: "edu-contact school structure", + PrimaryCLIPath: "edu-contact school structure", + }, + Description: "查询学校组织架构", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "get_school_structure"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询学校完整组织架构树", + UseWhen: []string{"需要查看学校的完整组织架构时"}, + AvoidWhen: []string{"查询学校班级列表用 school class-list"}, + Examples: []string{ + "dws edu-contact school structure --format json", + }, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + schoolPeriodsCmd := &cobra.Command{ + Use: "periods", + Short: "查询学校学段信息", + Long: `查询指定学校配置的所有学段信息,如小学、初中、高中等。所有已认证用户均可调用。`, + Example: ` dws edu-contact school periods + dws edu-contact school periods -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("edu-contact", "get_school_periods", nil) + }, + } + DeclareLeafMetadata(schoolPeriodsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "get_school_periods", + CanonicalPath: "edu-contact.get_school_periods", + CLIPath: "edu-contact school periods", + PrimaryCLIPath: "edu-contact school periods", + }, + Description: "查询学校学段信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "get_school_periods"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询学校配置的所有学段信息", + UseWhen: []string{"需要查询学校有哪些学段(小学/初中/高中等)时"}, + AvoidWhen: []string{"查询学校类型用 school type"}, + Examples: []string{ + "dws edu-contact school periods --format json", + }, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + schoolTypeCmd := &cobra.Command{ + Use: "type", + Short: "查询学校组织类型", + Long: `查询指定学校的教育组织类型。 +返回值:EDU_BASIC(基础教育/K12)、SELF_CLASS(自建班级)、UNIVERSITY(高校)、EDU_TRAINING(教培机构)。`, + Example: ` dws edu-contact school type + dws edu-contact school type -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("edu-contact", "get_edu_org_type", map[string]any{ + "input": "", + }) + }, + } + DeclareLeafMetadata(schoolTypeCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "get_edu_org_type", + CanonicalPath: "edu-contact.get_edu_org_type", + CLIPath: "edu-contact school type", + PrimaryCLIPath: "edu-contact school type", + }, + Description: "查询学校组织类型", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "get_edu_org_type"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询学校的教育组织类型", + UseWhen: []string{"需要查询学校属于哪种教育组织类型时"}, + AvoidWhen: []string{"查询学段信息用 school periods"}, + Examples: []string{ + "dws edu-contact school type --format json", + }, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + schoolStatsCmd := &cobra.Command{ + Use: "stats", + Short: "查询学校统计数据", + Long: `查询指定学校的统计数据。 +统计类型 --statistics-type 取值:1-学生、2-家长、4-班级;不指定则返回全部统计。 +仅限老师、班主任、管理员角色调用。`, + Example: ` dws edu-contact school stats + dws edu-contact school stats --statistics-type 1 + dws edu-contact school stats --statistics-type 4 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + input := map[string]any{} + if stStr, _ := cmd.Flags().GetString("statistics-type"); strings.TrimSpace(stStr) != "" { + st, err := strconv.ParseInt(strings.TrimSpace(stStr), 10, 64) + if err != nil { + return fmt.Errorf("--statistics-type 须为整数: %w", err) + } + input["statisticsType"] = st + } + return callMCPToolOnServer("edu-contact", "statistics_school", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(schoolStatsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "statistics_school", + CanonicalPath: "edu-contact.statistics_school", + CLIPath: "edu-contact school stats", + PrimaryCLIPath: "edu-contact school stats", + }, + Description: "查询学校统计数据", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "statistics_school"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询学校统计数据(学生/家长/班级数量)", + UseWhen: []string{"需要查询学校的学生数、家长数或班级数等统计信息时"}, + AvoidWhen: []string{"查询班级列表用 school class-list"}, + Examples: []string{ + "dws edu-contact school stats --statistics-type 1 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "statistics-type", Property: "input.statisticsType"}, + }, + }, + }) + + schoolClassListCmd := &cobra.Command{ + Use: "class-list", + Short: "查询学校所有班级列表", + Long: `查询指定学校下的所有班级列表。仅限老师、班主任、管理员角色调用。`, + Example: ` dws edu-contact school class-list + dws edu-contact school class-list -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("edu-contact", "get_org_class_list", nil) + }, + } + DeclareLeafMetadata(schoolClassListCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "get_org_class_list", + CanonicalPath: "edu-contact.get_org_class_list", + CLIPath: "edu-contact school class-list", + PrimaryCLIPath: "edu-contact school class-list", + }, + Description: "查询学校所有班级列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "get_org_class_list"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询学校下的所有班级列表", + UseWhen: []string{"需要获取学校下所有班级的列表时"}, + AvoidWhen: []string{"查询班级详情用 class detail"}, + Examples: []string{ + "dws edu-contact school class-list --format json", + }, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + // ════════════════════════════════════════════════════════════ + // class 子命令组 — 班级级查询 + // ════════════════════════════════════════════════════════════ + + classCmd := &cobra.Command{Use: "class", Short: "班级管理", RunE: groupRunE} + + classDetailCmd := &cobra.Command{ + Use: "detail", + Short: "查询班级详情", + Long: `查询指定班级的详细信息,包括班级名称、所属年级、学段、学生人数、班主任等。`, + Example: ` dws edu-contact class detail --dept-id 12345 + dws edu-contact class detail --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-contact", "get_class_detail", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(classDetailCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "get_class_detail", + CanonicalPath: "edu-contact.get_class_detail", + CLIPath: "edu-contact class detail", + PrimaryCLIPath: "edu-contact class detail", + }, + Description: "查询班级详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "get_class_detail"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询指定班级的详细信息", + UseWhen: []string{"需要查询某个班级的名称、年级、学段、学生人数、班主任等详情时"}, + AvoidWhen: []string{"查询班级学生列表用 class students"}, + Examples: []string{ + "dws edu-contact class detail --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + classStudentsCmd := &cobra.Command{ + Use: "students", + Short: "查询班级学生信息", + Long: `查询指定班级内的学生列表及班级详情。`, + Example: ` dws edu-contact class students --dept-id 12345 + dws edu-contact class students --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-contact", "query_classes_student_info", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(classStudentsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "query_classes_student_info", + CanonicalPath: "edu-contact.query_classes_student_info", + CLIPath: "edu-contact class students", + PrimaryCLIPath: "edu-contact class students", + }, + Description: "查询班级学生信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "query_classes_student_info"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询指定班级内的学生列表", + UseWhen: []string{"需要查询某个班级的学生列表及班级详情时"}, + AvoidWhen: []string{"查询班级老师用 class teachers"}, + Examples: []string{ + "dws edu-contact class students --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + classTeachersCmd := &cobra.Command{ + Use: "teachers", + Short: "查询班级的老师列表", + Long: `查询指定班级的所有老师信息,包括教授课程、是否为班主任等。`, + Example: ` dws edu-contact class teachers --dept-id 12345 + dws edu-contact class teachers --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-contact", "query_class_teachers", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(classTeachersCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "query_class_teachers", + CanonicalPath: "edu-contact.query_class_teachers", + CLIPath: "edu-contact class teachers", + PrimaryCLIPath: "edu-contact class teachers", + }, + Description: "查询班级的老师列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "query_class_teachers"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询指定班级的所有老师信息", + UseWhen: []string{"需要查询某个班级的老师列表(含课程、是否班主任)时"}, + AvoidWhen: []string{"查询班级学生用 class students"}, + Examples: []string{ + "dws edu-contact class teachers --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + classSameNameCmd := &cobra.Command{ + Use: "same-name", + Short: "查询班级内同名学生", + Long: `查询指定班级内是否存在同名学生。适用于点名、成绩录入等场景。`, + Example: ` dws edu-contact class same-name --dept-id 12345 + dws edu-contact class same-name --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-contact", "get_class_same_name_students", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(classSameNameCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "get_class_same_name_students", + CanonicalPath: "edu-contact.get_class_same_name_students", + CLIPath: "edu-contact class same-name", + PrimaryCLIPath: "edu-contact class same-name", + }, + Description: "查询班级内同名学生", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "get_class_same_name_students"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询班级内是否存在同名学生", + UseWhen: []string{"需要查询班级内是否有重名学生时"}, + AvoidWhen: []string{"查询班级学生列表用 class students"}, + Examples: []string{ + "dws edu-contact class same-name --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + classUserRoleCmd := &cobra.Command{ + Use: "user-role", + Short: "查询用户在班级内的角色", + Long: `查询当前用户在指定班级内的角色身份。`, + Example: ` dws edu-contact class user-role --dept-id 12345 + dws edu-contact class user-role --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-contact", "get_user_role_in_class", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(classUserRoleCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "get_user_role_in_class", + CanonicalPath: "edu-contact.get_user_role_in_class", + CLIPath: "edu-contact class user-role", + PrimaryCLIPath: "edu-contact class user-role", + }, + Description: "查询用户在班级内的角色", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "get_user_role_in_class"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询当前用户在指定班级内的角色身份", + UseWhen: []string{"需要查询用户在某个班级内的角色时"}, + AvoidWhen: []string{"查询用户在组织内角色用 school roles"}, + Examples: []string{ + "dws edu-contact class user-role --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + classSearchByNameCmd := &cobra.Command{ + Use: "search-by-name", + Short: "根据学生或家长姓名查询班级", + Long: `根据学生或家长姓名模糊搜索其所在的班级信息。 +--query-type 取值:student(学生)、guardian(家长)。仅限管理员角色调用。`, + Example: ` dws edu-contact class search-by-name --query-type student --name 张三 + dws edu-contact class search-by-name --query-type guardian --name 李四 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + queryType, _ := cmd.Flags().GetString("query-type") + queryType = strings.TrimSpace(queryType) + if queryType == "" { + return fmt.Errorf("--query-type 为必填参数") + } + name, _ := cmd.Flags().GetString("name") + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("--name 为必填参数") + } + return callMCPToolOnServer("edu-contact", "query_class_by_guardian_name", map[string]any{ + "input": map[string]any{ + "queryType": queryType, + "name": name, + }, + }) + }, + } + DeclareLeafMetadata(classSearchByNameCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "query_class_by_guardian_name", + CanonicalPath: "edu-contact.query_class_by_guardian_name", + CLIPath: "edu-contact class search-by-name", + PrimaryCLIPath: "edu-contact class search-by-name", + }, + Description: "根据学生或家长姓名查询班级", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "query_class_by_guardian_name"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "根据学生或家长姓名模糊搜索所在班级", + UseWhen: []string{"需要根据学生或家长的姓名查找其所在班级时"}, + AvoidWhen: []string{"根据老师姓名查班级用 class search-by-teacher"}, + Examples: []string{ + "dws edu-contact class search-by-name --query-type student --name 张三 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "query-type", Property: "input.queryType", Required: boolPtr(true)}, + {Name: "name", Property: "input.name", Required: boolPtr(true)}, + }, + }, + }) + + classHeadmasterCmd := &cobra.Command{ + Use: "headmaster", + Short: "根据班级名称查询班主任", + Long: `根据班级名称查询班主任信息。仅限管理员角色调用。`, + Example: ` dws edu-contact class headmaster --class-name 一年级1班 + dws edu-contact class headmaster --class-name 三年级2班 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + className, _ := cmd.Flags().GetString("class-name") + className = strings.TrimSpace(className) + if className == "" { + return fmt.Errorf("--class-name 为必填参数") + } + return callMCPToolOnServer("edu-contact", "query_headmaster_by_className", map[string]any{ + "input": map[string]any{"className": className}, + }) + }, + } + DeclareLeafMetadata(classHeadmasterCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "query_headmaster_by_className", + CanonicalPath: "edu-contact.query_headmaster_by_className", + CLIPath: "edu-contact class headmaster", + PrimaryCLIPath: "edu-contact class headmaster", + }, + Description: "根据班级名称查询班主任", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "query_headmaster_by_className"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "根据班级名称查询班主任信息", + UseWhen: []string{"需要通过班级名称查找班主任时"}, + AvoidWhen: []string{"查询班级所有老师用 class teachers"}, + Examples: []string{ + "dws edu-contact class headmaster --class-name 一年级1班 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-name", Property: "input.className", Required: boolPtr(true)}, + }, + }, + }) + + classSearchByTeacherCmd := &cobra.Command{ + Use: "search-by-teacher", + Short: "根据老师姓名查询班级", + Long: `根据教师姓名模糊搜索其任教的班级信息。 +通过通讯录模糊匹配教师,返回该教师所任教的班级列表及班级详情(班级ID、格式化班级名称等), +结果已按班级去重。仅限管理员角色调用。`, + Example: ` dws edu-contact class search-by-teacher --name 张老师 + dws edu-contact class search-by-teacher --name 李 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("--name 为必填参数") + } + return callMCPToolOnServer("edu-contact", "query_class_by_teacher_name", map[string]any{ + "input": map[string]any{ + "name": name, + }, + }) + }, + } + DeclareLeafMetadata(classSearchByTeacherCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "query_class_by_teacher_name", + CanonicalPath: "edu-contact.query_class_by_teacher_name", + CLIPath: "edu-contact class search-by-teacher", + PrimaryCLIPath: "edu-contact class search-by-teacher", + }, + Description: "根据老师姓名查询班级", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "query_class_by_teacher_name"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "根据教师姓名模糊搜索其任教的班级", + UseWhen: []string{"需要通过教师姓名查找其任教的班级时"}, + AvoidWhen: []string{"根据学生/家长姓名查班级用 class search-by-name"}, + Examples: []string{ + "dws edu-contact class search-by-teacher --name 张老师 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "name", Property: "input.name", Required: boolPtr(true)}, + }, + }, + }) + + classUpdateStudentCmd := &cobra.Command{ + Use: "update-student", + Short: "更新学生信息", + Long: `更新指定班级中某个学生的信息。 +可选更新学生姓名、学号及家长信息列表。 + +通过 --append-patriarch 控制家长更新模式: + - 完全更新家长信息(默认):不传 --append-patriarch 或 --append-patriarch=false,并传入完整的 --patriarchs JSON + - 增量添加新家长:--append-patriarch=true,仅传入需要新增的 --patriarchs JSON + - 不修改家长信息:--append-patriarch=true 且不传 --patriarchs + +--patriarchs JSON 格式示例: + [{"userId":"uid1","relation":"F","relationName":"父亲","phone":"13800138000"}] + relation 取值:F-父亲 M-母亲 GF-爷爷 GM-奶奶 O-其他 + +仅限管理员或班主任角色调用。`, + Example: ` # 仅更新学生姓名(不修改家长,需传 --append-patriarch 避免清空家长) + dws edu-contact class update-student --class-id 12345 --student-user-id userId1 --student-name 张三 --append-patriarch + # 仅更新学号 + dws edu-contact class update-student --class-id 12345 --student-user-id userId1 --student-number S001 --append-patriarch + # 全量更新家长信息(覆盖原有家长列表,不传 --append-patriarch) + dws edu-contact class update-student --class-id 12345 --student-user-id userId1 --patriarchs '[{"userId":"uid1","relation":"F","relationName":"父亲","phone":"13800138000"}]' + # 增量添加新家长(不影响已有家长) + dws edu-contact class update-student --class-id 12345 --student-user-id userId1 --patriarchs '[{"userId":"uid2","relation":"M","relationName":"母亲","phone":"13900139000"}]' --append-patriarch`, + RunE: func(cmd *cobra.Command, args []string) error { + classID, err := eduRequiredIntFlag(cmd, "class-id") + if err != nil { + return err + } + studentUserID, _ := cmd.Flags().GetString("student-user-id") + studentUserID = strings.TrimSpace(studentUserID) + if studentUserID == "" { + return fmt.Errorf("--student-user-id 为必填参数") + } + + input := map[string]any{ + "classId": classID, + "studentUserId": studentUserID, + } + + if studentName, _ := cmd.Flags().GetString("student-name"); strings.TrimSpace(studentName) != "" { + input["studentName"] = strings.TrimSpace(studentName) + } + if studentNumber, _ := cmd.Flags().GetString("student-number"); strings.TrimSpace(studentNumber) != "" { + input["studentNumber"] = strings.TrimSpace(studentNumber) + } + + appendPatriarch, _ := cmd.Flags().GetBool("append-patriarch") + input["appendPatriarch"] = appendPatriarch + + patriarchsJSON, _ := cmd.Flags().GetString("patriarchs") + if strings.TrimSpace(patriarchsJSON) != "" { + var patriarchs []map[string]any + if err := json.Unmarshal([]byte(patriarchsJSON), &patriarchs); err != nil { + return fmt.Errorf("--patriarchs JSON 格式错误: %w", err) + } + input["patriarchs"] = patriarchs + } + + return callMCPToolOnServer("edu-contact", "update_student", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(classUpdateStudentCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "update_student", + CanonicalPath: "edu-contact.update_student", + CLIPath: "edu-contact class update-student", + PrimaryCLIPath: "edu-contact class update-student", + }, + Description: "更新学生信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "update_student"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "更新指定班级中某个学生的姓名、学号或家长信息", + UseWhen: []string{"需要更新学生的姓名、学号或家长信息时"}, + AvoidWhen: []string{"修改学号用 class update-student-number;修改学生名称/家长关系用 class modify-student-info"}, + Examples: []string{ + "dws edu-contact class update-student --class-id 12345 --student-user-id userId1 --student-name 张三 --append-patriarch --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "student-user-id", Property: "input.studentUserId", Required: boolPtr(true)}, + {Name: "student-name", Property: "input.studentName"}, + {Name: "student-number", Property: "input.studentNumber"}, + {Name: "patriarchs", Property: "input.patriarchs"}, + {Name: "append-patriarch", Property: "input.appendPatriarch"}, + }, + }, + }) + + classAddStudentCmd := &cobra.Command{ + Use: "add-student", + Short: "添加班级学生", + Long: `向指定班级添加一名学生。 +必须提供学生手机号(--student-mobile)或至少一个家长手机号(通过 --mother/--father/--other-patriarchs 传入)。 +可选传入学生userId(已有学生时传入)、学号、虚拟账号ID。 +仅限管理员角色调用。 + +家长信息通过 --mother、--father、--other-patriarchs 传入 JSON 格式: + {"mobile":"13800138000","relation":"M","relationName":"母亲"} + relation 取值:F-父亲 M-母亲 GF-爷爷 GM-奶奶 O-其他 + +--other-patriarchs 为 JSON 数组,可传入多个其他家长。`, + Example: ` # 添加学生(学生手机号) + dws edu-contact class add-student --dept-id 12345 --student-name 张三 --student-mobile 13800138000 + # 添加学生并指定母亲信息(家长手机号) + dws edu-contact class add-student --dept-id 12345 --student-name 张三 --mother '{"mobile":"13800138000","relation":"M","relationName":"母亲"}' + # 添加学生并指定学号和父亲信息 + dws edu-contact class add-student --dept-id 12345 --student-name 张三 --student-number S001 --father '{"mobile":"13900139000","relation":"F","relationName":"父亲"}' + # 添加已有学生到班级 + dws edu-contact class add-student --dept-id 12345 --student-name 张三 --student-user-id existingUserId --student-mobile 13800138000`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + studentName, _ := cmd.Flags().GetString("student-name") + studentName = strings.TrimSpace(studentName) + if studentName == "" { + return fmt.Errorf("--student-name 为必填参数") + } + + input := map[string]any{ + "deptId": deptID, + "studentName": studentName, + } + + if studentUserID, _ := cmd.Flags().GetString("student-user-id"); strings.TrimSpace(studentUserID) != "" { + input["studentUserId"] = strings.TrimSpace(studentUserID) + } + if studentNumber, _ := cmd.Flags().GetString("student-number"); strings.TrimSpace(studentNumber) != "" { + input["studentNumber"] = strings.TrimSpace(studentNumber) + } + if virtualAccountID, _ := cmd.Flags().GetString("virtual-account-id"); strings.TrimSpace(virtualAccountID) != "" { + input["virtualAccountId"] = strings.TrimSpace(virtualAccountID) + } + + studentMobile, _ := cmd.Flags().GetString("student-mobile") + studentMobile = strings.TrimSpace(studentMobile) + if studentMobile != "" { + input["studentMobile"] = studentMobile + } + + hasMobile := studentMobile != "" + + if motherJSON, _ := cmd.Flags().GetString("mother"); strings.TrimSpace(motherJSON) != "" { + var mother map[string]any + if err := json.Unmarshal([]byte(motherJSON), &mother); err != nil { + return fmt.Errorf("--mother JSON 格式错误: %w", err) + } + input["mother"] = mother + hasMobile = true + } + if fatherJSON, _ := cmd.Flags().GetString("father"); strings.TrimSpace(fatherJSON) != "" { + var father map[string]any + if err := json.Unmarshal([]byte(fatherJSON), &father); err != nil { + return fmt.Errorf("--father JSON 格式错误: %w", err) + } + input["father"] = father + hasMobile = true + } + if otherJSON, _ := cmd.Flags().GetString("other-patriarchs"); strings.TrimSpace(otherJSON) != "" { + var others []map[string]any + if err := json.Unmarshal([]byte(otherJSON), &others); err != nil { + return fmt.Errorf("--other-patriarchs JSON 格式错误: %w", err) + } + input["otherPatriarchs"] = others + hasMobile = true + } + + if !hasMobile { + return fmt.Errorf("必须提供学生手机号(--student-mobile)或至少一个家长手机号(通过 --mother/--father/--other-patriarchs 传入)") + } + + return callMCPToolOnServer("edu-contact", "add_student", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(classAddStudentCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "add_student", + CanonicalPath: "edu-contact.add_student", + CLIPath: "edu-contact class add-student", + PrimaryCLIPath: "edu-contact class add-student", + }, + Description: "添加班级学生", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "add_student"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "向指定班级添加一名学生", + UseWhen: []string{"需要向班级添加新学生时"}, + AvoidWhen: []string{"添加非行政班学生用 class add-unofficial-student"}, + Examples: []string{ + "dws edu-contact class add-student --dept-id 12345 --student-name 张三 --student-mobile 13800138000 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "student-name", Property: "input.studentName", Required: boolPtr(true)}, + {Name: "student-mobile", Property: "input.studentMobile"}, + {Name: "student-user-id", Property: "input.studentUserId"}, + {Name: "student-number", Property: "input.studentNumber"}, + {Name: "virtual-account-id", Property: "input.virtualAccountId"}, + {Name: "mother", Property: "input.mother"}, + {Name: "father", Property: "input.father"}, + {Name: "other-patriarchs", Property: "input.otherPatriarchs"}, + }, + }, + }) + + classModifyStudentInfoCmd := &cobra.Command{ + Use: "modify-student-info", + Short: "修改学生信息", + Long: `修改指定班级中某个学生的名称或家长关系信息。 +可选修改学生名称和家长关系,两者至少填一项。 +修改家长关系时需同时传入家长 userId 和关系类型。 +操作有并发控制,同一用户短时间内不可重复操作。仅限班主任角色调用。 + +--relation 取值:父亲、母亲、爷爷、奶奶、哥哥、姐姐、其他`, + Example: ` # 修改学生名称 + dws edu-contact class modify-student-info --dept-id 12345 --target-user-id userId1 --nick 张三 + # 修改家长关系 + dws edu-contact class modify-student-info --dept-id 12345 --target-user-id userId1 --patriarch-user-id parentId1 --relation 父亲 + # 同时修改名称和家长关系 + dws edu-contact class modify-student-info --dept-id 12345 --target-user-id userId1 --nick 张三 --patriarch-user-id parentId1 --relation 母亲`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + targetUserID, _ := cmd.Flags().GetString("target-user-id") + targetUserID = strings.TrimSpace(targetUserID) + if targetUserID == "" { + return fmt.Errorf("--target-user-id 为必填参数") + } + + input := map[string]any{ + "deptId": deptID, + "targetUserId": targetUserID, + } + + nick, _ := cmd.Flags().GetString("nick") + nick = strings.TrimSpace(nick) + patriarchUserID, _ := cmd.Flags().GetString("patriarch-user-id") + patriarchUserID = strings.TrimSpace(patriarchUserID) + relation, _ := cmd.Flags().GetString("relation") + relation = strings.TrimSpace(relation) + + if nick == "" && patriarchUserID == "" { + return fmt.Errorf("--nick 和 --patriarch-user-id/--relation 至少填一项") + } + if nick != "" { + input["nick"] = nick + } + if patriarchUserID != "" { + if relation == "" { + return fmt.Errorf("修改家长关系时需同时传入 --relation") + } + input["patriarchUserId"] = patriarchUserID + input["relation"] = relation + } + + return callMCPToolOnServer("edu-contact", "modify_student_info", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(classModifyStudentInfoCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "modify_student_info", + CanonicalPath: "edu-contact.modify_student_info", + CLIPath: "edu-contact class modify-student-info", + PrimaryCLIPath: "edu-contact class modify-student-info", + }, + Description: "修改学生信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "modify_student_info"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "修改学生名称或家长关系信息", + UseWhen: []string{"需要修改学生名称或调整家长关系时"}, + AvoidWhen: []string{"更新学生姓名/学号/家长列表用 class update-student"}, + Examples: []string{ + "dws edu-contact class modify-student-info --dept-id 12345 --target-user-id userId1 --nick 张三 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "target-user-id", Property: "input.targetUserId", Required: boolPtr(true)}, + {Name: "nick", Property: "input.nick"}, + {Name: "patriarch-user-id", Property: "input.patriarchUserId"}, + {Name: "relation", Property: "input.relation"}, + }, + }, + }) + + classDeleteTeacherCmd := &cobra.Command{ + Use: "delete-teacher", + Short: "删除班级教师", + Long: `从指定班级中删除一名教师。 +返回操作是否成功。仅限管理员或班主任角色调用。`, + Example: ` dws edu-contact class delete-teacher --class-id 12345 --teacher-user-id userId1 + dws edu-contact class delete-teacher --class-id 12345 --teacher-user-id userId1 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + classID, err := eduRequiredIntFlag(cmd, "class-id") + if err != nil { + return err + } + teacherUserID, _ := cmd.Flags().GetString("teacher-user-id") + teacherUserID = strings.TrimSpace(teacherUserID) + if teacherUserID == "" { + return fmt.Errorf("--teacher-user-id 为必填参数") + } + return callMCPToolOnServer("edu-contact", "delete_teacher", map[string]any{ + "input": map[string]any{ + "classId": classID, + "teacherUserId": teacherUserID, + }, + }) + }, + } + DeclareLeafMetadata(classDeleteTeacherCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "delete_teacher", + CanonicalPath: "edu-contact.delete_teacher", + CLIPath: "edu-contact class delete-teacher", + PrimaryCLIPath: "edu-contact class delete-teacher", + }, + Description: "删除班级教师", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "delete_teacher"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "从指定班级中删除一名教师", + UseWhen: []string{"需要将某位教师从班级中移除时"}, + AvoidWhen: []string{"添加教师用 class add-teachers"}, + Examples: []string{ + "dws edu-contact class delete-teacher --class-id 12345 --teacher-user-id userId1 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "teacher-user-id", Property: "input.teacherUserId", Required: boolPtr(true)}, + }, + }, + }) + + classUpdateClassInfoCmd := &cobra.Command{ + Use: "update-info", + Short: "更新班级信息", + Long: `更新指定班级的基本信息。 +可选更新班级别名、预期学生人数及班级群名称。 +班级别名和群名称有长度限制且需通过内容安全校验。 +修改群名称时需同时传入群会话ID(--conversation-id)。 +仅限管理员或班主任角色调用。`, + Example: ` # 更新班级别名 + dws edu-contact class update-info --class-id 12345 --nick 火箭班 + # 更新预期学生人数 + dws edu-contact class update-info --class-id 12345 --expected-student-num 45 + # 更新班级群名称(需同时传 conversation-id) + dws edu-contact class update-info --class-id 12345 --group-name 一年级1班家长群 --conversation-id cid123456`, + RunE: func(cmd *cobra.Command, args []string) error { + classID, err := eduRequiredIntFlag(cmd, "class-id") + if err != nil { + return err + } + + input := map[string]any{ + "classId": classID, + } + + if nick, _ := cmd.Flags().GetString("nick"); strings.TrimSpace(nick) != "" { + input["nick"] = strings.TrimSpace(nick) + } + if expectedStr, _ := cmd.Flags().GetString("expected-student-num"); strings.TrimSpace(expectedStr) != "" { + expectedNum, err := strconv.ParseInt(strings.TrimSpace(expectedStr), 10, 64) + if err != nil { + return fmt.Errorf("--expected-student-num 须为整数: %w", err) + } + input["expectedStudentNum"] = expectedNum + } + + groupName, _ := cmd.Flags().GetString("group-name") + conversationID, _ := cmd.Flags().GetString("conversation-id") + groupName = strings.TrimSpace(groupName) + conversationID = strings.TrimSpace(conversationID) + if groupName != "" { + if conversationID == "" { + return fmt.Errorf("修改群名称时需同时传入 --conversation-id") + } + input["groupName"] = groupName + input["conversationId"] = conversationID + } + + return callMCPToolOnServer("edu-contact", "update_class_info", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(classUpdateClassInfoCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "update_class_info", + CanonicalPath: "edu-contact.update_class_info", + CLIPath: "edu-contact class update-info", + PrimaryCLIPath: "edu-contact class update-info", + }, + Description: "更新班级信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "update_class_info"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "更新班级别名、预期学生人数或班级群名称", + UseWhen: []string{"需要修改班级别名、预期人数或群名称时"}, + AvoidWhen: []string{"更新学生信息用 class update-student"}, + Examples: []string{ + "dws edu-contact class update-info --class-id 12345 --nick 火箭班 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "nick", Property: "input.nick"}, + {Name: "expected-student-num", Property: "input.expectedStudentNum"}, + {Name: "group-name", Property: "input.groupName"}, + {Name: "conversation-id", Property: "input.conversationId"}, + }, + }, + }) + + classUpdateStudentNumberCmd := &cobra.Command{ + Use: "update-student-number", + Short: "修改学生学号", + Long: `修改指定班级中某个学生的学号。 +学号有长度限制且需通过内容安全校验。仅限班主任或教师角色调用。`, + Example: ` dws edu-contact class update-student-number --class-id 12345 --student-user-id userId1 --student-number S001 + dws edu-contact class update-student-number --class-id 12345 --student-user-id userId1 --student-number S002 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + classID, err := eduRequiredIntFlag(cmd, "class-id") + if err != nil { + return err + } + studentUserID, _ := cmd.Flags().GetString("student-user-id") + studentUserID = strings.TrimSpace(studentUserID) + if studentUserID == "" { + return fmt.Errorf("--student-user-id 为必填参数") + } + studentNumber, _ := cmd.Flags().GetString("student-number") + studentNumber = strings.TrimSpace(studentNumber) + if studentNumber == "" { + return fmt.Errorf("--student-number 为必填参数") + } + return callMCPToolOnServer("edu-contact", "update_student_number", map[string]any{ + "input": map[string]any{ + "classId": classID, + "studentUserId": studentUserID, + "studentNumber": studentNumber, + }, + }) + }, + } + DeclareLeafMetadata(classUpdateStudentNumberCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "update_student_number", + CanonicalPath: "edu-contact.update_student_number", + CLIPath: "edu-contact class update-student-number", + PrimaryCLIPath: "edu-contact class update-student-number", + }, + Description: "修改学生学号", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "update_student_number"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "修改指定班级中某个学生的学号", + UseWhen: []string{"需要修改学生学号时"}, + AvoidWhen: []string{"更新学生姓名/家长用 class update-student"}, + Examples: []string{ + "dws edu-contact class update-student-number --class-id 12345 --student-user-id userId1 --student-number S001 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-id", Property: "input.classId", Required: boolPtr(true)}, + {Name: "student-user-id", Property: "input.studentUserId", Required: boolPtr(true)}, + {Name: "student-number", Property: "input.studentNumber", Required: boolPtr(true)}, + }, + }, + }) + + classAddUnofficialStudentCmd := &cobra.Command{ + Use: "add-unofficial-student", + Short: "添加非行政班学生", + Long: `向指定非行政班级批量添加学生。 +支持批量操作,学生数量不可超过上限。仅限管理员角色调用。`, + Example: ` dws edu-contact class add-unofficial-student --dept-id 12345 --student-staff-ids staffId1,staffId2 + dws edu-contact class add-unofficial-student --dept-id 12345 --student-staff-ids staffId1 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + staffIdsStr, _ := cmd.Flags().GetString("student-staff-ids") + staffIdsStr = strings.TrimSpace(staffIdsStr) + if staffIdsStr == "" { + return fmt.Errorf("--student-staff-ids 为必填参数") + } + var studentStaffIds []string + for _, sid := range strings.Split(staffIdsStr, ",") { + if s := strings.TrimSpace(sid); s != "" { + studentStaffIds = append(studentStaffIds, s) + } + } + if len(studentStaffIds) == 0 { + return fmt.Errorf("--student-staff-ids 不能为空") + } + return callMCPToolOnServer("edu-contact", "add_unofficial_class_student", map[string]any{ + "input": map[string]any{ + "deptId": deptID, + "studentStaffIds": studentStaffIds, + }, + }) + }, + } + DeclareLeafMetadata(classAddUnofficialStudentCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "add_unofficial_class_student", + CanonicalPath: "edu-contact.add_unofficial_class_student", + CLIPath: "edu-contact class add-unofficial-student", + PrimaryCLIPath: "edu-contact class add-unofficial-student", + }, + Description: "添加非行政班学生", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "add_unofficial_class_student"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "向非行政班级批量添加学生", + UseWhen: []string{"需要向非行政班级批量添加学生时"}, + AvoidWhen: []string{"添加行政班学生用 class add-student"}, + Examples: []string{ + "dws edu-contact class add-unofficial-student --dept-id 12345 --student-staff-ids staffId1,staffId2 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "student-staff-ids", Property: "input.studentStaffIds", Required: boolPtr(true)}, + }, + }, + }) + + classDeleteStudentsCmd := &cobra.Command{ + Use: "delete-students", + Short: "批量删除学生", + Long: `从指定班级中批量删除学生。 +返回操作是否成功。仅限管理员角色调用。`, + Example: ` dws edu-contact class delete-students --dept-id 12345 --student-user-ids userId1,userId2 + dws edu-contact class delete-students --dept-id 12345 --student-user-ids userId1 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + studentIdsStr, _ := cmd.Flags().GetString("student-user-ids") + studentIdsStr = strings.TrimSpace(studentIdsStr) + if studentIdsStr == "" { + return fmt.Errorf("--student-user-ids 为必填参数") + } + var studentUserIds []string + for _, uid := range strings.Split(studentIdsStr, ",") { + if u := strings.TrimSpace(uid); u != "" { + studentUserIds = append(studentUserIds, u) + } + } + if len(studentUserIds) == 0 { + return fmt.Errorf("--student-user-ids 不能为空") + } + return callMCPToolOnServer("edu-contact", "delete_students", map[string]any{ + "input": map[string]any{ + "deptId": deptID, + "studentUserIds": studentUserIds, + }, + }) + }, + } + DeclareLeafMetadata(classDeleteStudentsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "delete_students", + CanonicalPath: "edu-contact.delete_students", + CLIPath: "edu-contact class delete-students", + PrimaryCLIPath: "edu-contact class delete-students", + }, + Description: "批量删除学生", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "delete_students"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "从指定班级中批量删除学生", + UseWhen: []string{"需要从班级中批量移除学生时"}, + AvoidWhen: []string{"学生移班用 class move-student"}, + Examples: []string{ + "dws edu-contact class delete-students --dept-id 12345 --student-user-ids userId1,userId2 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "student-user-ids", Property: "input.studentUserIds", Required: boolPtr(true)}, + }, + }, + }) + + classUpdateStudentMobileCmd := &cobra.Command{ + Use: "update-student-mobile", + Short: "修改学生手机号", + Long: `修改指定班级中某个学生的手机号。 +手机号会进行格式校验。仅限管理员角色调用。`, + Example: ` dws edu-contact class update-student-mobile --dept-id 12345 --student-user-id userId1 --mobile 13800138000 + dws edu-contact class update-student-mobile --dept-id 12345 --student-user-id userId1 --mobile 13800138000 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + studentUserID, _ := cmd.Flags().GetString("student-user-id") + studentUserID = strings.TrimSpace(studentUserID) + if studentUserID == "" { + return fmt.Errorf("--student-user-id 为必填参数") + } + mobile, _ := cmd.Flags().GetString("mobile") + mobile = strings.TrimSpace(mobile) + if mobile == "" { + return fmt.Errorf("--mobile 为必填参数") + } + return callMCPToolOnServer("edu-contact", "update_student_mobile", map[string]any{ + "input": map[string]any{ + "deptId": deptID, + "studentUserId": studentUserID, + "mobile": mobile, + }, + }) + }, + } + DeclareLeafMetadata(classUpdateStudentMobileCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "update_student_mobile", + CanonicalPath: "edu-contact.update_student_mobile", + CLIPath: "edu-contact class update-student-mobile", + PrimaryCLIPath: "edu-contact class update-student-mobile", + }, + Description: "修改学生手机号", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "update_student_mobile"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "修改指定班级中某个学生的手机号", + UseWhen: []string{"需要修改学生手机号时"}, + AvoidWhen: []string{"更新学生姓名/学号用 class update-student"}, + Examples: []string{ + "dws edu-contact class update-student-mobile --dept-id 12345 --student-user-id userId1 --mobile 13800138000 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "student-user-id", Property: "input.studentUserId", Required: boolPtr(true)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(true)}, + }, + }, + }) + + classMoveStudentCmd := &cobra.Command{ + Use: "move-student", + Short: "学生移班", + Long: `将学生从一个班级转移到另一个班级。 +支持批量操作,学生数量不可超过上限。仅限管理员或班主任角色调用。`, + Example: ` dws edu-contact class move-student --student-user-ids userId1,userId2 --origin-class-id 12345 --target-class-id 67890 + dws edu-contact class move-student --student-user-ids userId1 --origin-class-id 12345 --target-class-id 67890 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + originClassID, err := eduRequiredIntFlag(cmd, "origin-class-id") + if err != nil { + return err + } + targetClassID, err := eduRequiredIntFlag(cmd, "target-class-id") + if err != nil { + return err + } + studentIdsStr, _ := cmd.Flags().GetString("student-user-ids") + studentIdsStr = strings.TrimSpace(studentIdsStr) + if studentIdsStr == "" { + return fmt.Errorf("--student-user-ids 为必填参数") + } + var studentUserIds []string + for _, uid := range strings.Split(studentIdsStr, ",") { + if u := strings.TrimSpace(uid); u != "" { + studentUserIds = append(studentUserIds, u) + } + } + if len(studentUserIds) == 0 { + return fmt.Errorf("--student-user-ids 不能为空") + } + return callMCPToolOnServer("edu-contact", "move_student", map[string]any{ + "input": map[string]any{ + "studentUserIds": studentUserIds, + "originClassId": originClassID, + "targetClassId": targetClassID, + }, + }) + }, + } + DeclareLeafMetadata(classMoveStudentCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "move_student", + CanonicalPath: "edu-contact.move_student", + CLIPath: "edu-contact class move-student", + PrimaryCLIPath: "edu-contact class move-student", + }, + Description: "学生移班", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "move_student"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "将学生从一个班级转移到另一个班级", + UseWhen: []string{"需要将学生从原班级转移到目标班级时"}, + AvoidWhen: []string{"删除学生用 class delete-students"}, + Examples: []string{ + "dws edu-contact class move-student --student-user-ids userId1 --origin-class-id 12345 --target-class-id 67890 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "student-user-ids", Property: "input.studentUserIds", Required: boolPtr(true)}, + {Name: "origin-class-id", Property: "input.originClassId", Required: boolPtr(true)}, + {Name: "target-class-id", Property: "input.targetClassId", Required: boolPtr(true)}, + }, + }, + }) + + classAddTeachersCmd := &cobra.Command{ + Use: "add-teachers", + Short: "批量添加班级教师", + Long: `批量将教师添加到指定班级。 +教师列表会自动去重,单次最多添加50名教师。仅限管理员角色调用。 + +--is-adviser 取值: + 0 = 普通老师(默认) + 1 = 班主任`, + Example: ` dws edu-contact class add-teachers --dept-id 12345 --teacher-user-ids userId1,userId2 + dws edu-contact class add-teachers --dept-id 12345 --teacher-user-ids userId1,userId2 --is-adviser 1 + dws edu-contact class add-teachers --dept-id 12345 --teacher-user-ids userId1 --is-adviser 0 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + teacherIdsStr, _ := cmd.Flags().GetString("teacher-user-ids") + teacherIdsStr = strings.TrimSpace(teacherIdsStr) + if teacherIdsStr == "" { + return fmt.Errorf("--teacher-user-ids 为必填参数") + } + var teacherUserIds []string + for _, uid := range strings.Split(teacherIdsStr, ",") { + if u := strings.TrimSpace(uid); u != "" { + teacherUserIds = append(teacherUserIds, u) + } + } + if len(teacherUserIds) == 0 { + return fmt.Errorf("--teacher-user-ids 不能为空") + } + if len(teacherUserIds) > 50 { + return fmt.Errorf("单次最多添加50名教师,当前 %d 名", len(teacherUserIds)) + } + isAdviserStr, _ := cmd.Flags().GetString("is-adviser") + isAdviser := int64(0) + if strings.TrimSpace(isAdviserStr) != "" { + isAdviser, err = strconv.ParseInt(strings.TrimSpace(isAdviserStr), 10, 64) + if err != nil || (isAdviser != 0 && isAdviser != 1) { + return fmt.Errorf("--is-adviser 须为 0(普通老师)或 1(班主任)") + } + } + return callMCPToolOnServer("edu-contact", "batch_add_class_teacher", map[string]any{ + "input": map[string]any{ + "deptId": deptID, + "teacherUserIds": teacherUserIds, + "isAdviser": isAdviser, + }, + }) + }, + } + DeclareLeafMetadata(classAddTeachersCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "batch_add_class_teacher", + CanonicalPath: "edu-contact.batch_add_class_teacher", + CLIPath: "edu-contact class add-teachers", + PrimaryCLIPath: "edu-contact class add-teachers", + }, + Description: "批量添加班级教师", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "batch_add_class_teacher"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "批量将教师添加到指定班级", + UseWhen: []string{"需要向班级批量添加教师时"}, + AvoidWhen: []string{"删除教师用 class delete-teacher"}, + Examples: []string{ + "dws edu-contact class add-teachers --dept-id 12345 --teacher-user-ids userId1,userId2 --is-adviser 1 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + {Name: "teacher-user-ids", Property: "input.teacherUserIds", Required: boolPtr(true)}, + {Name: "is-adviser", Property: "input.isAdviser"}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // family 子命令组 — 家庭关系查询 + // ════════════════════════════════════════════════════════════ + + familyCmd := &cobra.Command{Use: "family", Short: "家庭关系查询", RunE: groupRunE} + + familyChildrenCmd := &cobra.Command{ + Use: "children", + Short: "查询家长的孩子信息", + Long: `查询当前家长在指定组织(学校)内关联的所有孩子信息。仅限家长角色调用。`, + Example: ` dws edu-contact family children + dws edu-contact family children -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("edu-contact", "query_children_in_org", nil) + }, + } + DeclareLeafMetadata(familyChildrenCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "query_children_in_org", + CanonicalPath: "edu-contact.query_children_in_org", + CLIPath: "edu-contact family children", + PrimaryCLIPath: "edu-contact family children", + }, + Description: "查询家长的孩子信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "query_children_in_org"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询当前家长关联的所有孩子信息", + UseWhen: []string{"需要查询家长在学校内关联的孩子信息时"}, + AvoidWhen: []string{"查询学生的家长信息用 family parents"}, + Examples: []string{ + "dws edu-contact family children --format json", + }, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + familyParentsCmd := &cobra.Command{ + Use: "parents", + Short: "查询学生的家长信息", + Long: `查询当前学生在指定组织(学校)内关联的所有家长信息。`, + Example: ` dws edu-contact family parents + dws edu-contact family parents -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("edu-contact", "query_patriarch_in_org", nil) + }, + } + DeclareLeafMetadata(familyParentsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "query_patriarch_in_org", + CanonicalPath: "edu-contact.query_patriarch_in_org", + CLIPath: "edu-contact family parents", + PrimaryCLIPath: "edu-contact family parents", + }, + Description: "查询学生的家长信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "query_patriarch_in_org"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询当前学生关联的所有家长信息", + UseWhen: []string{"需要查询学生在学校内关联的家长信息时"}, + AvoidWhen: []string{"查询家长的孩子信息用 family children"}, + Examples: []string{ + "dws edu-contact family parents --format json", + }, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + // ════════════════════════════════════════════════════════════ + // teacher 子命令组 — 教师查询 + // ════════════════════════════════════════════════════════════ + + teacherCmd := &cobra.Command{Use: "teacher", Short: "教师管理", RunE: groupRunE} + + teacherClassesCmd := &cobra.Command{ + Use: "classes", + Short: "查询老师管理的班级列表", + Long: `查询当前老师或班主任在指定组织(学校)内管理的班级列表。`, + Example: ` dws edu-contact teacher classes + dws edu-contact teacher classes -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("edu-contact", "query_teacher_classes", nil) + }, + } + DeclareLeafMetadata(teacherClassesCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "query_teacher_classes", + CanonicalPath: "edu-contact.query_teacher_classes", + CLIPath: "edu-contact teacher classes", + PrimaryCLIPath: "edu-contact teacher classes", + }, + Description: "查询老师管理的班级列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "query_teacher_classes"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询当前老师管理的班级列表", + UseWhen: []string{"需要查询老师或班主任管理的班级列表时"}, + AvoidWhen: []string{"查询班级老师列表用 class teachers"}, + Examples: []string{ + "dws edu-contact teacher classes --format json", + }, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + teacherUpdateCourseCmd := &cobra.Command{ + Use: "update-course", + Short: "更新教师任教科目", + Long: `批量更新教师在各班级的任教科目信息。 +返回操作是否成功。仅限班主任或教师角色调用。 + +--teacher-class-infos JSON 格式示例: + [{"classId":12345,"courseCode":"c1","courseName":"语文"}] + +每项包含: + - classId: 班级ID + - courseCode: 科目编码 + - courseName: 科目名称`, + Example: ` dws edu-contact teacher update-course --teacher-class-infos '[{"classId":12345,"courseCode":"c1","courseName":"语文"}]' + dws edu-contact teacher update-course --teacher-class-infos '[{"classId":12345,"courseCode":"c1","courseName":"语文"},{"classId":67890,"courseCode":"c2","courseName":"数学"}]' -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + infosJSON, _ := cmd.Flags().GetString("teacher-class-infos") + infosJSON = strings.TrimSpace(infosJSON) + if infosJSON == "" { + return fmt.Errorf("--teacher-class-infos 为必填参数") + } + var teacherClassInfos []map[string]any + if err := json.Unmarshal([]byte(infosJSON), &teacherClassInfos); err != nil { + return fmt.Errorf("--teacher-class-infos JSON 格式错误: %w", err) + } + if len(teacherClassInfos) == 0 { + return fmt.Errorf("--teacher-class-infos 不能为空数组") + } + return callMCPToolOnServer("edu-contact", "update_teacher_course", map[string]any{ + "input": map[string]any{ + "teacherClassInfos": teacherClassInfos, + }, + }) + }, + } + DeclareLeafMetadata(teacherUpdateCourseCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-contact", + Name: "update_teacher_course", + CanonicalPath: "edu-contact.update_teacher_course", + CLIPath: "edu-contact teacher update-course", + PrimaryCLIPath: "edu-contact teacher update-course", + }, + Description: "更新教师任教科目", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-contact", RPCName: "update_teacher_course"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "批量更新教师在各班级的任教科目信息", + UseWhen: []string{"需要更新教师任教的科目信息时"}, + AvoidWhen: []string{"添加教师到班级用 class add-teachers"}, + Examples: []string{ + "dws edu-contact teacher update-course --teacher-class-infos '[{\"classId\":12345,\"courseCode\":\"c1\",\"courseName\":\"语文\"}]' --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "teacher-class-infos", Property: "input.teacherClassInfos", Required: boolPtr(true)}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // flags + 构建命令树 + // ════════════════════════════════════════════════════════════ + + // school flags + schoolStatsCmd.Flags().String("statistics-type", "", "统计类型:1-学生 2-家长 4-班级") + + // class flags + classDetailCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classStudentsCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classTeachersCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classSameNameCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classUserRoleCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classSearchByNameCmd.Flags().String("query-type", "", "查询类型(必填):student / guardian") + classSearchByNameCmd.Flags().String("name", "", "姓名(必填)") + classHeadmasterCmd.Flags().String("class-name", "", "班级名称(必填)") + classSearchByTeacherCmd.Flags().String("name", "", "教师姓名(必填)") + classUpdateStudentCmd.Flags().String("class-id", "", "班级 ID(必填)") + classUpdateStudentCmd.Flags().String("student-user-id", "", "学生 userId(必填)") + classUpdateStudentCmd.Flags().String("student-name", "", "学生姓名(可选)") + classUpdateStudentCmd.Flags().String("student-number", "", "学号(可选)") + classUpdateStudentCmd.Flags().String("patriarchs", "", "家长信息 JSON 数组(可选)") + classUpdateStudentCmd.Flags().Bool("append-patriarch", false, "家长更新模式:传入则增量添加,不传则全量更新(默认全量更新)") + classAddStudentCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classAddStudentCmd.Flags().String("student-name", "", "学生姓名(必填)") + classAddStudentCmd.Flags().String("student-mobile", "", "学生手机号(与家长手机号至少填一个)") + classAddStudentCmd.Flags().String("student-user-id", "", "学生 userId(可选,已有学生时传入)") + classAddStudentCmd.Flags().String("student-number", "", "学号(可选)") + classAddStudentCmd.Flags().String("virtual-account-id", "", "虚拟账号 ID(可选)") + classAddStudentCmd.Flags().String("mother", "", "母亲信息 JSON(可选)") + classAddStudentCmd.Flags().String("father", "", "父亲信息 JSON(可选)") + classAddStudentCmd.Flags().String("other-patriarchs", "", "其他家长信息 JSON 数组(可选)") + classModifyStudentInfoCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classModifyStudentInfoCmd.Flags().String("target-user-id", "", "学生 userId(必填)") + classModifyStudentInfoCmd.Flags().String("nick", "", "学生名称(可选)") + classModifyStudentInfoCmd.Flags().String("patriarch-user-id", "", "家长 userId(修改家长关系时必填)") + classModifyStudentInfoCmd.Flags().String("relation", "", "家长关系:父亲/母亲/爷爷/奶奶/哥哥/姐姐/其他(修改家长关系时必填)") + classDeleteTeacherCmd.Flags().String("class-id", "", "班级 ID(必填)") + classDeleteTeacherCmd.Flags().String("teacher-user-id", "", "教师 userId(必填)") + classUpdateClassInfoCmd.Flags().String("class-id", "", "班级 ID(必填)") + classUpdateClassInfoCmd.Flags().String("nick", "", "班级别名(可选)") + classUpdateClassInfoCmd.Flags().String("expected-student-num", "", "预期学生人数(可选)") + classUpdateClassInfoCmd.Flags().String("group-name", "", "班级群名称(可选,需同时传 --conversation-id)") + classUpdateClassInfoCmd.Flags().String("conversation-id", "", "群会话 ID(修改群名称时必填)") + classUpdateStudentNumberCmd.Flags().String("class-id", "", "班级 ID(必填)") + classUpdateStudentNumberCmd.Flags().String("student-user-id", "", "学生 userId(必填)") + classUpdateStudentNumberCmd.Flags().String("student-number", "", "新学号(必填)") + classAddUnofficialStudentCmd.Flags().String("dept-id", "", "非行政班级 ID(必填)") + classAddUnofficialStudentCmd.Flags().String("student-staff-ids", "", "学生 staffId 列表,逗号分隔(必填)") + classDeleteStudentsCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classDeleteStudentsCmd.Flags().String("student-user-ids", "", "学生 userId 列表,逗号分隔(必填)") + classUpdateStudentMobileCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classUpdateStudentMobileCmd.Flags().String("student-user-id", "", "学生 userId(必填)") + classUpdateStudentMobileCmd.Flags().String("mobile", "", "修改后的手机号(必填)") + classMoveStudentCmd.Flags().String("student-user-ids", "", "学生 userId 列表,逗号分隔(必填)") + classMoveStudentCmd.Flags().String("origin-class-id", "", "原班级 ID(必填)") + classMoveStudentCmd.Flags().String("target-class-id", "", "目标班级 ID(必填)") + classAddTeachersCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classAddTeachersCmd.Flags().String("teacher-user-ids", "", "教师 userId 列表,逗号分隔(必填,最多50个)") + classAddTeachersCmd.Flags().String("is-adviser", "0", "教师角色:0-普通老师 1-班主任(默认 0)") + + schoolCmd.AddCommand(schoolRolesCmd, schoolStructureCmd, schoolPeriodsCmd, schoolTypeCmd, schoolStatsCmd, schoolClassListCmd) + classCmd.AddCommand(classDetailCmd, classStudentsCmd, classTeachersCmd, classSameNameCmd, classUserRoleCmd, classSearchByNameCmd, classHeadmasterCmd, classSearchByTeacherCmd, classAddStudentCmd, classModifyStudentInfoCmd, classDeleteTeacherCmd, classUpdateClassInfoCmd, classUpdateStudentCmd, classUpdateStudentNumberCmd, classAddUnofficialStudentCmd, classDeleteStudentsCmd, classUpdateStudentMobileCmd, classMoveStudentCmd, classAddTeachersCmd) + familyCmd.AddCommand(familyChildrenCmd, familyParentsCmd) + teacherUpdateCourseCmd.Flags().String("teacher-class-infos", "", "教师班级课程信息 JSON 数组(必填)") + teacherCmd.AddCommand(teacherClassesCmd, teacherUpdateCourseCmd) + + root.AddCommand(schoolCmd, classCmd, familyCmd, teacherCmd) + + return root +} + +func eduRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) { + v, _ := cmd.Flags().GetString(name) + v = strings.TrimSpace(v) + if v == "" { + return 0, fmt.Errorf("--%s 为必填参数", name) + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("--%s 须为整数: %w", name, err) + } + return n, nil +} diff --git a/internal/helpers/edu_contact_test.go b/internal/helpers/edu_contact_test.go new file mode 100644 index 00000000..c3b8e5c9 --- /dev/null +++ b/internal/helpers/edu_contact_test.go @@ -0,0 +1,583 @@ +package helpers + +import ( + "io" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func newTestEduContactRoot() *cobra.Command { + return newEduContactCommand() +} + +// ────────────────────────────────────────────────────────── +// 命令注册测试 — 验证所有子命令路径是否正确注册 +// ────────────────────────────────────────────────────────── + +func TestEduContactCommandTree(t *testing.T) { + root := newTestEduContactRoot() + + paths := [][]string{ + // school + {"school", "roles"}, + {"school", "structure"}, + {"school", "periods"}, + {"school", "type"}, + {"school", "stats"}, + {"school", "class-list"}, + // class — 原有 + {"class", "detail"}, + {"class", "students"}, + {"class", "teachers"}, + {"class", "same-name"}, + {"class", "user-role"}, + {"class", "search-by-name"}, + {"class", "headmaster"}, + // class — 新增 + {"class", "search-by-teacher"}, + {"class", "add-student"}, + {"class", "add-teachers"}, + {"class", "add-unofficial-student"}, + {"class", "delete-students"}, + {"class", "delete-teacher"}, + {"class", "modify-student-info"}, + {"class", "move-student"}, + {"class", "update-info"}, + {"class", "update-student"}, + {"class", "update-student-mobile"}, + {"class", "update-student-number"}, + // family + {"family", "children"}, + {"family", "parents"}, + // teacher + {"teacher", "classes"}, + {"teacher", "update-course"}, + } + + for _, path := range paths { + if _, _, err := root.Find(path); err != nil { + t.Errorf("command path %v not found: %v", path, err) + } + } +} + +// ────────────────────────────────────────────────────────── +// 参数校验测试 — 验证必填参数缺失时返回错误 +// ────────────────────────────────────────────────────────── + +func executeCommand(root *cobra.Command, args ...string) error { + root.SetArgs(args) + return root.Execute() +} + +func TestClassSearchByTeacher_MissingName(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "search-by-teacher") + if err == nil { + t.Fatal("expected error for missing --name, got nil") + } +} + +func TestClassAddTeachers_MissingDeptId(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "add-teachers", "--teacher-user-ids", "uid1") + if err == nil { + t.Fatal("expected error for missing --dept-id, got nil") + } +} + +func TestClassAddTeachers_MissingTeacherUserIds(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345") + if err == nil { + t.Fatal("expected error for missing --teacher-user-ids, got nil") + } +} + +func TestClassAddTeachers_InvalidIsAdviser(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345", "--teacher-user-ids", "uid1", "--is-adviser", "3") + if err == nil { + t.Fatal("expected error for invalid --is-adviser, got nil") + } +} + +func TestClassMoveStudent_MissingOriginClassId(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "move-student", "--student-user-ids", "uid1", "--target-class-id", "67890") + if err == nil { + t.Fatal("expected error for missing --origin-class-id, got nil") + } +} + +func TestClassMoveStudent_MissingStudentUserIds(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "move-student", "--origin-class-id", "12345", "--target-class-id", "67890") + if err == nil { + t.Fatal("expected error for missing --student-user-ids, got nil") + } +} + +func TestClassUpdateStudentMobile_MissingMobile(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "update-student-mobile", "--dept-id", "12345", "--student-user-id", "uid1") + if err == nil { + t.Fatal("expected error for missing --mobile, got nil") + } +} + +func TestClassDeleteStudents_MissingDeptId(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "delete-students", "--student-user-ids", "uid1") + if err == nil { + t.Fatal("expected error for missing --dept-id, got nil") + } +} + +func TestClassDeleteStudents_MissingStudentUserIds(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "delete-students", "--dept-id", "12345") + if err == nil { + t.Fatal("expected error for missing --student-user-ids, got nil") + } +} + +func TestClassAddUnofficialStudent_MissingDeptId(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "add-unofficial-student", "--student-staff-ids", "sid1") + if err == nil { + t.Fatal("expected error for missing --dept-id, got nil") + } +} + +func TestClassAddUnofficialStudent_MissingStaffIds(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "add-unofficial-student", "--dept-id", "12345") + if err == nil { + t.Fatal("expected error for missing --student-staff-ids, got nil") + } +} + +func TestClassUpdateStudent_MissingClassId(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "update-student", "--student-user-id", "uid1", "--student-name", "张三", "--append-patriarch") + if err == nil { + t.Fatal("expected error for missing --class-id, got nil") + } +} + +func TestClassUpdateStudent_MissingStudentUserId(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-name", "张三", "--append-patriarch") + if err == nil { + t.Fatal("expected error for missing --student-user-id, got nil") + } +} + +func TestClassUpdateStudent_InvalidPatriarchsJSON(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-user-id", "uid1", "--patriarchs", "invalid-json", "--append-patriarch") + if err == nil { + t.Fatal("expected error for invalid --patriarchs JSON, got nil") + } +} + +func TestClassUpdateStudentNumber_MissingStudentNumber(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "update-student-number", "--class-id", "12345", "--student-user-id", "uid1") + if err == nil { + t.Fatal("expected error for missing --student-number, got nil") + } +} + +func TestClassUpdateInfo_MissingClassId(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "update-info", "--nick", "火箭班") + if err == nil { + t.Fatal("expected error for missing --class-id, got nil") + } +} + +func TestClassUpdateInfo_GroupNameWithoutConversationId(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "update-info", "--class-id", "12345", "--group-name", "测试群") + if err == nil { + t.Fatal("expected error for --group-name without --conversation-id, got nil") + } +} + +func TestClassDeleteTeacher_MissingTeacherUserId(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "delete-teacher", "--class-id", "12345") + if err == nil { + t.Fatal("expected error for missing --teacher-user-id, got nil") + } +} + +func TestClassModifyStudentInfo_MissingBothNickAndPatriarch(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1") + if err == nil { + t.Fatal("expected error for missing both --nick and --patriarch-user-id, got nil") + } +} + +func TestClassModifyStudentInfo_PatriarchWithoutRelation(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1", "--patriarch-user-id", "pid1") + if err == nil { + t.Fatal("expected error for --patriarch-user-id without --relation, got nil") + } +} + +func TestClassAddStudent_MissingStudentName(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-mobile", "13800138000") + if err == nil { + t.Fatal("expected error for missing --student-name, got nil") + } +} + +func TestClassAddStudent_MissingMobile(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三") + if err == nil { + t.Fatal("expected error for missing mobile (student or parent), got nil") + } +} + +func TestClassAddStudent_InvalidMotherJSON(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三", "--mother", "bad-json") + if err == nil { + t.Fatal("expected error for invalid --mother JSON, got nil") + } +} + +func TestTeacherUpdateCourse_MissingTeacherClassInfos(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "teacher", "update-course") + if err == nil { + t.Fatal("expected error for missing --teacher-class-infos, got nil") + } +} + +func TestTeacherUpdateCourse_InvalidJSON(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "not-json") + if err == nil { + t.Fatal("expected error for invalid --teacher-class-infos JSON, got nil") + } +} + +func TestTeacherUpdateCourse_EmptyArray(t *testing.T) { + root := newTestEduContactRoot() + err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "[]") + if err == nil { + t.Fatal("expected error for empty --teacher-class-infos array, got nil") + } +} + +// ────────────────────────────────────────────────────────── +// Happy-path 测试 — 每个 leaf 命令的成功分支(经由 dry-run caller) +// 以及所有可选字段分支,用于把 changed-code 覆盖率补到 100%。 +// ────────────────────────────────────────────────────────── + +// withEduContactCaller installs a dry-run capture caller so happy-path command +// execution exercises each RunE up to the callMCPToolOnServer dispatch without +// requiring a live MCP transport. +func withEduContactCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{dryRun: true} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func TestEduContactHappyPaths(t *testing.T) { + withEduContactCaller(t) + cases := [][]string{ + // school + {"school", "roles"}, + {"school", "structure"}, + {"school", "periods"}, + {"school", "type"}, + {"school", "stats"}, + {"school", "stats", "--statistics-type", "1"}, + {"school", "class-list"}, + // class — 读操作 + {"class", "detail", "--dept-id", "123"}, + {"class", "students", "--dept-id", "123"}, + {"class", "teachers", "--dept-id", "123"}, + {"class", "same-name", "--dept-id", "123"}, + {"class", "user-role", "--dept-id", "123"}, + {"class", "search-by-name", "--query-type", "student", "--name", "张三"}, + {"class", "headmaster", "--class-name", "一年级1班"}, + {"class", "search-by-teacher", "--name", "张老师"}, + // class — update-student 各分支 + {"class", "update-student", "--class-id", "123", "--student-user-id", "u1", + "--student-name", "张三", "--student-number", "S1", "--append-patriarch", + "--patriarchs", `[{"userId":"uid1","relation":"F"}]`}, + {"class", "update-student", "--class-id", "123", "--student-user-id", "u1"}, + // class — add-student 各手机号来源分支 + {"class", "add-student", "--dept-id", "123", "--student-name", "张三", + "--student-mobile", "13800138000", "--student-user-id", "u1", + "--student-number", "S1", "--virtual-account-id", "v1"}, + {"class", "add-student", "--dept-id", "123", "--student-name", "张三", + "--mother", `{"mobile":"13800138000","relation":"M"}`}, + {"class", "add-student", "--dept-id", "123", "--student-name", "张三", + "--father", `{"mobile":"13900139000","relation":"F"}`}, + {"class", "add-student", "--dept-id", "123", "--student-name", "张三", + "--other-patriarchs", `[{"mobile":"13700137000","relation":"O"}]`}, + // class — modify-student-info 两个分支 + {"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "张三"}, + {"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", + "--patriarch-user-id", "p1", "--relation", "父亲"}, + // class — delete-teacher + {"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"}, + // class — update-info 三个可选分支 + {"class", "update-info", "--class-id", "123", "--nick", "火箭班"}, + {"class", "update-info", "--class-id", "123", "--expected-student-num", "45"}, + {"class", "update-info", "--class-id", "123", "--group-name", "家长群", "--conversation-id", "cid1"}, + // class — update-student-number + {"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S1"}, + // class — add-unofficial-student + {"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"}, + // class — delete-students + {"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"}, + // class — update-student-mobile + {"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"}, + // class — move-student + {"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"}, + // class — add-teachers 默认/班主任 + {"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"}, + {"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1", "--is-adviser", "1"}, + // family + {"family", "children"}, + {"family", "parents"}, + // teacher + {"teacher", "classes"}, + {"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"c1","courseName":"语文"}]`}, + } + for _, args := range cases { + root := newTestEduContactRoot() + if err := executeCommand(root, args...); err != nil { + t.Errorf("happy path %v returned error: %v", args, err) + } + } +} + +// TestEduContactErrorPathsRemaining covers validation branches not exercised by +// the existing error tests, ensuring 100% changed-code coverage. +func TestEduContactErrorPathsRemaining(t *testing.T) { + cases := []struct { + name string + args []string + }{ + // eduRequiredIntFlag:空值 + 非整数 + {"stats-invalid-type", []string{"school", "stats", "--statistics-type", "abc"}}, + {"detail-missing-dept", []string{"class", "detail"}}, + {"detail-invalid-dept", []string{"class", "detail", "--dept-id", "abc"}}, + {"students-missing-dept", []string{"class", "students"}}, + {"teachers-missing-dept", []string{"class", "teachers"}}, + {"samename-missing-dept", []string{"class", "same-name"}}, + {"userrole-missing-dept", []string{"class", "user-role"}}, + {"searchbyname-missing-querytype", []string{"class", "search-by-name", "--name", "张三"}}, + {"searchbyname-missing-name", []string{"class", "search-by-name", "--query-type", "student"}}, + {"headmaster-missing-classname", []string{"class", "headmaster"}}, + // update-student + {"updatestudent-missing-classid", []string{"class", "update-student", "--student-user-id", "u1"}}, + {"updatestudent-missing-userid", []string{"class", "update-student", "--class-id", "123"}}, + {"updatestudent-invalid-patriarchs", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1", "--patriarchs", "not-json"}}, + // add-student + {"addstudent-missing-dept", []string{"class", "add-student", "--student-name", "张三"}}, + {"addstudent-missing-name", []string{"class", "add-student", "--dept-id", "123"}}, + {"addstudent-invalid-father", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--father", "not-json"}}, + {"addstudent-invalid-other", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--other-patriarchs", "not-json"}}, + {"addstudent-no-mobile", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三"}}, + // modify-student-info + {"modify-missing-dept", []string{"class", "modify-student-info", "--target-user-id", "u1", "--nick", "张三"}}, + {"modify-missing-target", []string{"class", "modify-student-info", "--dept-id", "123", "--nick", "张三"}}, + // delete-teacher + {"deleteteacher-missing-classid", []string{"class", "delete-teacher", "--teacher-user-id", "u1"}}, + // update-info + {"updateinfo-missing-classid", []string{"class", "update-info", "--nick", "火箭班"}}, + {"updateinfo-invalid-expected", []string{"class", "update-info", "--class-id", "123", "--expected-student-num", "abc"}}, + // update-student-number + {"usn-missing-classid", []string{"class", "update-student-number", "--student-user-id", "u1", "--student-number", "S1"}}, + {"usn-missing-userid", []string{"class", "update-student-number", "--class-id", "123", "--student-number", "S1"}}, + // add-unofficial-student + {"unofficial-missing-dept", []string{"class", "add-unofficial-student", "--student-staff-ids", "s1"}}, + {"unofficial-empty-staffids", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", ",,"}}, + // delete-students + {"deletestudents-missing-userids", []string{"class", "delete-students", "--dept-id", "123"}}, + {"deletestudents-empty-userids", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", ",,"}}, + // update-student-mobile + {"usm-missing-dept", []string{"class", "update-student-mobile", "--student-user-id", "u1", "--mobile", "13800138000"}}, + {"usm-missing-userid", []string{"class", "update-student-mobile", "--dept-id", "123", "--mobile", "13800138000"}}, + // move-student + {"move-missing-target", []string{"class", "move-student", "--student-user-ids", "u1", "--origin-class-id", "123"}}, + {"move-empty-userids", []string{"class", "move-student", "--origin-class-id", "123", "--target-class-id", "456", "--student-user-ids", ",,"}}, + // add-teachers + {"addteachers-empty-userids", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", ",,"}}, + {"addteachers-too-many", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", strings.Repeat("u,", 51) + "u"}}, + } + for _, tc := range cases { + root := newTestEduContactRoot() + if err := executeCommand(root, tc.args...); err == nil { + t.Errorf("%s: expected error, got nil", tc.name) + } + } +} + +// ────────────────────────────────────────────────────────── +// Dispatch 验证测试 — 验证命令正确派发到 MCP Server +// ────────────────────────────────────────────────────────── + +// withEduContactDispatchCaller installs a non-dry-run capture caller so that +// callMCPToolOnServer goes through deps.Caller.CallTool and we can verify +// the dispatched productID, tool name, and args. +func withEduContactDispatchCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func TestEduContactDispatch(t *testing.T) { + t.Run("class detail dispatches get_class_detail with deptId", func(t *testing.T) { + caller := withEduContactDispatchCaller(t) + root := newEduContactCommand() + root.SetArgs([]string{"class", "detail", "--dept-id", "123"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != "edu-contact" { + t.Errorf("productID = %q, want %q", caller.productID, "edu-contact") + } + if caller.tool != "get_class_detail" { + t.Errorf("tool = %q, want %q", caller.tool, "get_class_detail") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args) + } + if input["deptId"] != int64(123) { + t.Errorf("input[deptId] = %v (%T), want int64(123)", input["deptId"], input["deptId"]) + } + }) + + t.Run("class search-by-name dispatches query_class_by_guardian_name", func(t *testing.T) { + caller := withEduContactDispatchCaller(t) + root := newEduContactCommand() + root.SetArgs([]string{"class", "search-by-name", "--query-type", "student", "--name", "张三"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != "edu-contact" { + t.Errorf("productID = %q, want %q", caller.productID, "edu-contact") + } + if caller.tool != "query_class_by_guardian_name" { + t.Errorf("tool = %q, want %q", caller.tool, "query_class_by_guardian_name") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args) + } + if input["queryType"] != "student" { + t.Errorf("input[queryType] = %v, want %q", input["queryType"], "student") + } + if input["name"] != "张三" { + t.Errorf("input[name] = %v, want %q", input["name"], "张三") + } + }) + + t.Run("school stats dispatches statistics_school with statisticsType", func(t *testing.T) { + caller := withEduContactDispatchCaller(t) + root := newEduContactCommand() + root.SetArgs([]string{"school", "stats", "--statistics-type", "2"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != "edu-contact" { + t.Errorf("productID = %q, want %q", caller.productID, "edu-contact") + } + if caller.tool != "statistics_school" { + t.Errorf("tool = %q, want %q", caller.tool, "statistics_school") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args) + } + if input["statisticsType"] != int64(2) { + t.Errorf("input[statisticsType] = %v (%T), want int64(2)", input["statisticsType"], input["statisticsType"]) + } + }) + + t.Run("class add-teachers dispatches batch_add_class_teacher with list and isAdviser", func(t *testing.T) { + caller := withEduContactDispatchCaller(t) + root := newEduContactCommand() + root.SetArgs([]string{"class", "add-teachers", "--dept-id", "789", "--teacher-user-ids", "t1,t2", "--is-adviser", "1"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != "edu-contact" { + t.Errorf("productID = %q, want %q", caller.productID, "edu-contact") + } + if caller.tool != "batch_add_class_teacher" { + t.Errorf("tool = %q, want %q", caller.tool, "batch_add_class_teacher") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args) + } + if input["deptId"] != int64(789) { + t.Errorf("input[deptId] = %v (%T), want int64(789)", input["deptId"], input["deptId"]) + } + teacherUserIds, ok := input["teacherUserIds"].([]string) + if !ok { + t.Fatalf("input[teacherUserIds] is not []string: %#v", input["teacherUserIds"]) + } + if len(teacherUserIds) != 2 || teacherUserIds[0] != "t1" || teacherUserIds[1] != "t2" { + t.Errorf("input[teacherUserIds] = %v, want [t1 t2]", teacherUserIds) + } + if input["isAdviser"] != int64(1) { + t.Errorf("input[isAdviser] = %v (%T), want int64(1)", input["isAdviser"], input["isAdviser"]) + } + }) + + t.Run("class move-student dispatches move_student with list and two int flags", func(t *testing.T) { + caller := withEduContactDispatchCaller(t) + root := newEduContactCommand() + root.SetArgs([]string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "100", "--target-class-id", "200"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != "edu-contact" { + t.Errorf("productID = %q, want %q", caller.productID, "edu-contact") + } + if caller.tool != "move_student" { + t.Errorf("tool = %q, want %q", caller.tool, "move_student") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] is not map[string]any: %#v", caller.args) + } + studentUserIds, ok := input["studentUserIds"].([]string) + if !ok { + t.Fatalf("input[studentUserIds] is not []string: %#v", input["studentUserIds"]) + } + if len(studentUserIds) != 2 || studentUserIds[0] != "u1" || studentUserIds[1] != "u2" { + t.Errorf("input[studentUserIds] = %v, want [u1 u2]", studentUserIds) + } + if input["originClassId"] != int64(100) { + t.Errorf("input[originClassId] = %v (%T), want int64(100)", input["originClassId"], input["originClassId"]) + } + if input["targetClassId"] != int64(200) { + t.Errorf("input[targetClassId] = %v (%T), want int64(200)", input["targetClassId"], input["targetClassId"]) + } + }) +} diff --git a/internal/helpers/edu_family_group.go b/internal/helpers/edu_family_group.go new file mode 100644 index 00000000..e655f5f9 --- /dev/null +++ b/internal/helpers/edu_family_group.go @@ -0,0 +1,605 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +// ────────────────────────────────────────────────────────── +// dws edu-familygroup — 家庭群管理 +// 共 6 个工具,按 group(读操作)/ manage(写操作)分组 +// 参考 wukong/extensions/vendors/dingtalk/eduFamilyGroup.go 迁移 +// ────────────────────────────────────────────────────────── + +func newEduFamilyGroupCommand() *cobra.Command { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: "edu-familygroup", + Selection: contract.ProductSelectionDecl{ + AgentSummary: "家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制", + UseWhen: []string{ + "用户要查询或管理钉钉家庭群、添加孩子、邀请家长或控制学生应用权限。", + }, + AvoidWhen: []string{ + "家校通讯录用 edu-contact;班级师生群用 edu-group;家校应用/作业/打卡用 edu-app。", + }, + }, + }) + root := &cobra.Command{ + Use: "edu-familygroup", + Short: "家庭群", + Long: `钉钉家庭群管理:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制等。`, + Hidden: true, + RunE: groupRunE, + } + + // ════════════════════════════════════════════════════════════ + // group 子命令组 — 家庭群读操作 + // ════════════════════════════════════════════════════════════ + + groupCmd := &cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE} + + groupCheckExistsCmd := &cobra.Command{ + Use: "check-exists", + Short: "检查家庭群是否存在", + Long: `根据传入的 uid 拉取该用户所有家庭组织,按家庭群名称匹配判断家庭群是否存在。 +仅当存在同名家庭且其群会话 cid 非空时,才认为家庭群存在,返回 true,否则返回 false。 +面向家长(GUARDIAN)角色。`, + Example: ` dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家" + dws edu-familygroup group check-exists --uid 12345 --group-name "小明一家" -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid") + if err != nil { + return err + } + groupName, err := eduFamilyGroupRequiredStringFlag(cmd, "group-name") + if err != nil { + return err + } + return callMCPToolOnServer("edu-familygroup", "check_family_group_exists", map[string]any{ + "input": map[string]any{"uid": uid, "groupName": groupName}, + }) + }, + } + DeclareLeafMetadata(groupCheckExistsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-familygroup", + Name: "check_family_group_exists", + CanonicalPath: "edu-familygroup.check_family_group_exists", + CLIPath: "edu-familygroup group check-exists", + PrimaryCLIPath: "edu-familygroup group check-exists", + }, + Description: "检查家庭群是否存在", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "check_family_group_exists"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "检查家庭群是否存在", + UseWhen: []string{"需要判断指定用户名下是否存在同名家庭群时"}, + AvoidWhen: []string{"查询家庭成员信息用 list-children"}, + Examples: []string{ + "dws edu-familygroup group check-exists --uid 12345 --group-name \"小明一家\" --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "uid", Property: "input.uid", Required: boolPtr(true)}, + {Name: "group-name", Property: "input.groupName", Required: boolPtr(true)}, + }, + }, + }) + + groupListChildrenCmd := &cobra.Command{ + Use: "list-children", + Short: "查询家长绑定的孩子列表", + Long: `查询当前用户(uid)作为家长身份所在家庭中的所有孩子信息(不限家庭组织), +包含孩子基本信息及关联的学生号列表。底层按 uid 读扩散并完成家长身份校验,无孩子时返回空列表。 +面向家长(GUARDIAN)角色。`, + Example: ` dws edu-familygroup group list-children --uid 12345 + dws edu-familygroup group list-children --uid 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid") + if err != nil { + return err + } + return callMCPToolOnServer("edu-familygroup", "listBoundChildren", map[string]any{ + "input": map[string]any{"uid": uid}, + }) + }, + } + DeclareLeafMetadata(groupListChildrenCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-familygroup", + Name: "listBoundChildren", + CanonicalPath: "edu-familygroup.listBoundChildren", + CLIPath: "edu-familygroup group list-children", + PrimaryCLIPath: "edu-familygroup group list-children", + }, + Description: "查询家长绑定的孩子列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "listBoundChildren"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询家长绑定的孩子列表", + UseWhen: []string{"需要查询指定家长 uid 绑定的所有孩子及关联学生号信息时"}, + AvoidWhen: []string{"查看家庭群是否存在用 check-exists"}, + Examples: []string{ + "dws edu-familygroup group list-children --uid 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "uid", Property: "input.uid", Required: boolPtr(true)}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // manage 子命令组 — 家庭群写操作 + // ════════════════════════════════════════════════════════════ + + manageCmd := &cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE} + + manageCreateCmd := &cobra.Command{ + Use: "create", + Short: "创建家庭群", + Long: `以 uid 作为创建人创建一个新的家庭,创建家庭时会同时创建家庭群,返回结果中携带群会话 cid。 +children 为 JSON 数组,每个元素包含 name(必填)、students(必填,含 corpId + staffId)、 +birthday / gender / nick / avatar / period / grade / mobile(均可选)。 +addGroup 为 JSON 对象,含 schoolCorpId / schoolStaffId / inviteDingtalkId / inviteId(均可选)。 +新建家庭场景下无需前置家长身份校验,创建人合法性由底层校验单元完成。`, + Example: ` dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]' + dws edu-familygroup manage create --uid 12345 --children '[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}]' --source 1`, + RunE: func(cmd *cobra.Command, args []string) error { + uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid") + if err != nil { + return err + } + childrenRaw, _ := cmd.Flags().GetString("children") + if strings.TrimSpace(childrenRaw) == "" { + return fmt.Errorf("--children 为必填参数") + } + var children []any + if err := json.Unmarshal([]byte(childrenRaw), &children); err != nil { + return fmt.Errorf("--children 须为合法 JSON 数组: %w", err) + } + if err := eduFamilyGroupValidateChildren(children); err != nil { + return err + } + input := map[string]any{"uid": uid, "children": children} + if v, _ := cmd.Flags().GetString("add-group"); strings.TrimSpace(v) != "" { + var addGroup map[string]any + if err := json.Unmarshal([]byte(v), &addGroup); err != nil { + return fmt.Errorf("--add-group 须为合法 JSON 对象: %w", err) + } + if addGroup == nil { + return fmt.Errorf("--add-group 须为 JSON 对象,不能为 null") + } + input["addGroup"] = addGroup + } + if v, _ := cmd.Flags().GetInt("source"); cmd.Flags().Changed("source") { + input["source"] = v + } + return callMCPToolOnServer("edu-familygroup", "create_family_group", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(manageCreateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-familygroup", + Name: "create_family_group", + CanonicalPath: "edu-familygroup.create_family_group", + CLIPath: "edu-familygroup manage create", + PrimaryCLIPath: "edu-familygroup manage create", + }, + Description: "创建家庭群", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "create_family_group"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建家庭群并同步创建家庭组织", + UseWhen: []string{"需要以指定 uid 创建新的家庭群,同时注册孩子信息并生成群会话时"}, + AvoidWhen: []string{"已有家庭群要加孩子用 add-child"}, + Examples: []string{ + "dws edu-familygroup manage create --uid 12345 --children '[{\"name\":\"小明\",\"students\":[{\"corpId\":\"dingxxx\",\"staffId\":\"stu001\"}]}]' --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "uid", Property: "input.uid", Required: boolPtr(true)}, + {Name: "children", Property: "input.children", Required: boolPtr(true)}, + {Name: "add-group", Property: "input.addGroup"}, + {Name: "source", Property: "input.source"}, + }, + }, + }) + + manageInviteParentCmd := &cobra.Command{ + Use: "invite-parent", + Short: "短信邀请家长加入家庭群", + Long: `通过短信向指定手机号的家长发送家庭群邀请链接,家长点击链接后加入当前家庭组织及家庭群。 +返回 true 表示邀请短信已成功发送。仅家长(GUARDIAN)角色可调用。`, + Example: ` dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 + dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id") + if err != nil { + return err + } + uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid") + if err != nil { + return err + } + mobile, err := eduFamilyGroupRequiredStringFlag(cmd, "mobile") + if err != nil { + return err + } + return callMCPToolOnServer("edu-familygroup", "invite_parent_to_familygroup", map[string]any{ + "input": map[string]any{"orgId": orgID, "uid": uid, "mobile": mobile}, + }) + }, + } + DeclareLeafMetadata(manageInviteParentCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-familygroup", + Name: "invite_parent_to_familygroup", + CanonicalPath: "edu-familygroup.invite_parent_to_familygroup", + CLIPath: "edu-familygroup manage invite-parent", + PrimaryCLIPath: "edu-familygroup manage invite-parent", + }, + Description: "短信邀请家长加入家庭群", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "invite_parent_to_familygroup"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "通过短信邀请家长加入家庭群", + UseWhen: []string{"需要向指定手机号发送家庭群邀请短信时"}, + AvoidWhen: []string{"添加孩子到家庭群用 add-child"}, + Examples: []string{ + "dws edu-familygroup manage invite-parent --org-id 12345 --uid 67890 --mobile 13800138000 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "org-id", Property: "input.orgId", Required: boolPtr(true)}, + {Name: "uid", Property: "input.uid", Required: boolPtr(true)}, + {Name: "mobile", Property: "input.mobile", Required: boolPtr(true)}, + }, + }, + }) + + manageAddChildCmd := &cobra.Command{ + Use: "add-child", + Short: "为家庭群添加孩子", + Long: `为指定家庭群添加孩子,支持三种方式(由底层自动路由): + - 仅传 --mobile:手机号邀请链路,该手机号对应的钉钉账号被邀请加入家庭群 + - 仅传 --students:直接生成学生号链路,选中学生后创建孩子并绑定关系 + - 同时传 --mobile + --students:mobile 优先,走手机号邀请链路 +mobile 与 students 至少传一个。 +students 为 JSON 数组,每个元素含 schoolOrgId(整数)和 studentStaffId(字符串),均必填。 +仅家长(GUARDIAN)角色可调用。`, + Example: ` dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000 + dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --students '[{"schoolOrgId":111,"studentStaffId":"stu001"}]'`, + RunE: func(cmd *cobra.Command, args []string) error { + orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id") + if err != nil { + return err + } + uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid") + if err != nil { + return err + } + name, err := eduFamilyGroupRequiredStringFlag(cmd, "name") + if err != nil { + return err + } + mobile, _ := cmd.Flags().GetString("mobile") + mobile = strings.TrimSpace(mobile) + studentsRaw, _ := cmd.Flags().GetString("students") + studentsRaw = strings.TrimSpace(studentsRaw) + if mobile == "" && studentsRaw == "" { + return fmt.Errorf("--mobile 与 --students 至少传一个") + } + input := map[string]any{"orgId": orgID, "uid": uid, "name": name} + if mobile != "" { + input["mobile"] = mobile + } + if studentsRaw != "" { + var students []any + if err := json.Unmarshal([]byte(studentsRaw), &students); err != nil { + return fmt.Errorf("--students 须为合法 JSON 数组: %w", err) + } + if err := eduFamilyGroupValidateStudents(students); err != nil { + return err + } + input["students"] = students + } + return callMCPToolOnServer("edu-familygroup", "add_child_to_family_group", map[string]any{ + "input": input, + }) + }, + } + DeclareLeafMetadata(manageAddChildCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-familygroup", + Name: "add_child_to_family_group", + CanonicalPath: "edu-familygroup.add_child_to_family_group", + CLIPath: "edu-familygroup manage add-child", + PrimaryCLIPath: "edu-familygroup manage add-child", + }, + Description: "为家庭群添加孩子", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "add_child_to_family_group"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "为已有家庭群添加孩子", + UseWhen: []string{"需要向已有家庭群添加新孩子(通过手机号邀请或直接绑定学生号)时"}, + AvoidWhen: []string{"创建全新家庭群用 create"}, + Examples: []string{ + "dws edu-familygroup manage add-child --org-id 12345 --uid 67890 --name 小明 --mobile 13900139000 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "org-id", Property: "input.orgId", Required: boolPtr(true)}, + {Name: "uid", Property: "input.uid", Required: boolPtr(true)}, + {Name: "name", Property: "input.name", Required: boolPtr(true)}, + {Name: "mobile", Property: "input.mobile"}, + {Name: "students", Property: "input.students"}, + }, + }, + }) + + manageToggleAppCmd := &cobra.Command{ + Use: "toggle-app", + Short: "开启或关闭学生应用权限", + Long: `为指定学生号开启或关闭应用权限。 +支持的应用类型(--app-type): + - XIAOTIANDI:小天地(学生圈) + - LEARNING_VIDEO:学习视频 +直接覆写权限状态,天然幂等。仅家长(GUARDIAN)角色可调用。`, + Example: ` dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true + dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type LEARNING_VIDEO --open false`, + RunE: func(cmd *cobra.Command, args []string) error { + orgID, err := eduFamilyGroupRequiredIntFlag(cmd, "org-id") + if err != nil { + return err + } + uid, err := eduFamilyGroupRequiredIntFlag(cmd, "uid") + if err != nil { + return err + } + childStaffID, err := eduFamilyGroupRequiredStringFlag(cmd, "child-staff-id") + if err != nil { + return err + } + appType, err := eduFamilyGroupRequiredStringFlag(cmd, "app-type") + if err != nil { + return err + } + if appType != "XIAOTIANDI" && appType != "LEARNING_VIDEO" { + return fmt.Errorf("--app-type 须为 XIAOTIANDI 或 LEARNING_VIDEO") + } + openStr, err := eduFamilyGroupRequiredStringFlag(cmd, "open") + if err != nil { + return err + } + var open bool + switch strings.ToLower(openStr) { + case "true": + open = true + case "false": + open = false + default: + return fmt.Errorf("--open 须为 true 或 false") + } + return callMCPToolOnServer("edu-familygroup", "toggle_student_app", map[string]any{ + "input": map[string]any{ + "orgId": orgID, "uid": uid, "childStaffId": childStaffID, + "appType": appType, "open": open, + }, + }) + }, + } + DeclareLeafMetadata(manageToggleAppCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-familygroup", + Name: "toggle_student_app", + CanonicalPath: "edu-familygroup.toggle_student_app", + CLIPath: "edu-familygroup manage toggle-app", + PrimaryCLIPath: "edu-familygroup manage toggle-app", + }, + Description: "开启或关闭学生应用权限", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-familygroup", RPCName: "toggle_student_app"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "开启或关闭学生应用权限", + UseWhen: []string{"需要为指定学生号开启或关闭小天地/学习视频应用权限时"}, + AvoidWhen: []string{"管理家庭群成员用 add-child / invite-parent"}, + Examples: []string{ + "dws edu-familygroup manage toggle-app --org-id 12345 --uid 67890 --child-staff-id staff001 --app-type XIAOTIANDI --open true --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "org-id", Property: "input.orgId", Required: boolPtr(true)}, + {Name: "uid", Property: "input.uid", Required: boolPtr(true)}, + {Name: "child-staff-id", Property: "input.childStaffId", Required: boolPtr(true)}, + {Name: "app-type", Property: "input.appType", Required: boolPtr(true)}, + {Name: "open", Property: "input.open", Required: boolPtr(true)}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // flags + 构建命令树 + // ════════════════════════════════════════════════════════════ + + // group(读操作)flags + groupCheckExistsCmd.Flags().String("uid", "", "用户 uid(必填)") + groupCheckExistsCmd.Flags().String("group-name", "", "家庭群名称(必填)") + groupListChildrenCmd.Flags().String("uid", "", "家长 uid(必填)") + + // manage(写操作)flags + manageCreateCmd.Flags().String("uid", "", "创建人 uid(必填)") + manageCreateCmd.Flags().String("children", "", "孩子信息 JSON 数组(必填)") + manageCreateCmd.Flags().String("add-group", "", "同学群信息 JSON 对象(可选)") + manageCreateCmd.Flags().Int("source", 0, "渠道来源(可选)") + + manageInviteParentCmd.Flags().String("org-id", "", "家庭组织 ID(必填)") + manageInviteParentCmd.Flags().String("uid", "", "操作人 uid(必填)") + manageInviteParentCmd.Flags().String("mobile", "", "被邀请家长手机号(必填)") + + manageAddChildCmd.Flags().String("org-id", "", "家庭组织 ID(必填)") + manageAddChildCmd.Flags().String("uid", "", "操作人 uid(必填)") + manageAddChildCmd.Flags().String("name", "", "孩子姓名(必填)") + manageAddChildCmd.Flags().String("mobile", "", "孩子手机号(可选,与 --students 至少传一个)") + manageAddChildCmd.Flags().String("students", "", "待关联学生号 JSON 数组(可选,每项含 schoolOrgId + studentStaffId)") + + manageToggleAppCmd.Flags().String("org-id", "", "家庭组织 ID(必填)") + manageToggleAppCmd.Flags().String("uid", "", "家长 uid(必填)") + manageToggleAppCmd.Flags().String("child-staff-id", "", "孩子在家庭组织中的 staffId(必填)") + manageToggleAppCmd.Flags().String("app-type", "", "应用类型:XIAOTIANDI / LEARNING_VIDEO(必填)") + manageToggleAppCmd.Flags().String("open", "", "true=开启 / false=关闭(必填)") + + groupCmd.AddCommand(groupCheckExistsCmd, groupListChildrenCmd) + manageCmd.AddCommand(manageCreateCmd, manageInviteParentCmd, manageAddChildCmd, manageToggleAppCmd) + + root.AddCommand(groupCmd, manageCmd) + + return root +} + +// eduFamilyGroupValidateChildren validates the --children payload: the array +// must be non-empty, each child must carry a non-empty name and a non-empty +// students array, and each student must carry corpId + staffId. +func eduFamilyGroupValidateChildren(children []any) error { + if len(children) == 0 { + return fmt.Errorf("--children 不能为空数组,至少需包含一个孩子") + } + for i, c := range children { + child, ok := c.(map[string]any) + if !ok { + return fmt.Errorf("--children[%d] 须为 JSON 对象", i) + } + name, _ := child["name"].(string) + if strings.TrimSpace(name) == "" { + return fmt.Errorf("--children[%d].name 为必填字段", i) + } + students, ok := child["students"].([]any) + if !ok || len(students) == 0 { + return fmt.Errorf("--children[%d].students 为必填字段且不能为空数组", i) + } + for j, s := range students { + student, ok := s.(map[string]any) + if !ok { + return fmt.Errorf("--children[%d].students[%d] 须为 JSON 对象", i, j) + } + corpID, _ := student["corpId"].(string) + if strings.TrimSpace(corpID) == "" { + return fmt.Errorf("--children[%d].students[%d].corpId 为必填字段", i, j) + } + staffID, _ := student["staffId"].(string) + if strings.TrimSpace(staffID) == "" { + return fmt.Errorf("--children[%d].students[%d].staffId 为必填字段", i, j) + } + } + } + return nil +} + +// eduFamilyGroupValidateStudents validates the --students payload: the array +// must be non-empty and each element must carry a numeric schoolOrgId and a +// non-empty studentStaffId. +func eduFamilyGroupValidateStudents(students []any) error { + if len(students) == 0 { + return fmt.Errorf("--students 不能为空数组,至少需包含一个学生号") + } + for i, s := range students { + student, ok := s.(map[string]any) + if !ok { + return fmt.Errorf("--students[%d] 须为 JSON 对象", i) + } + switch student["schoolOrgId"].(type) { + case float64, int, int64, json.Number: + default: + return fmt.Errorf("--students[%d].schoolOrgId 为必填字段且须为整数", i) + } + staffID, _ := student["studentStaffId"].(string) + if strings.TrimSpace(staffID) == "" { + return fmt.Errorf("--students[%d].studentStaffId 为必填字段", i) + } + } + return nil +} + +// eduFamilyGroupRequiredIntFlag extracts a required integer flag, returning an +// error if the flag is empty or not a valid integer. +func eduFamilyGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) { + v, _ := cmd.Flags().GetString(name) + v = strings.TrimSpace(v) + if v == "" { + return 0, fmt.Errorf("--%s 为必填参数", name) + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("--%s 须为整数: %w", name, err) + } + return n, nil +} + +// eduFamilyGroupRequiredStringFlag extracts a required string flag. +func eduFamilyGroupRequiredStringFlag(cmd *cobra.Command, name string) (string, error) { + v, _ := cmd.Flags().GetString(name) + v = strings.TrimSpace(v) + if v == "" { + return "", fmt.Errorf("--%s 为必填参数", name) + } + return v, nil +} diff --git a/internal/helpers/edu_family_group_test.go b/internal/helpers/edu_family_group_test.go new file mode 100644 index 00000000..c4f5f73e --- /dev/null +++ b/internal/helpers/edu_family_group_test.go @@ -0,0 +1,286 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "io" + "strings" + "testing" +) + +func withEduFamilyGroupCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{dryRun: true} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func TestEduFamilyGroupHappyPaths(t *testing.T) { + cases := [][]string{ + {"group", "check-exists", "--uid", "1", "--group-name", "小明一家"}, + {"group", "list-children", "--uid", "1"}, + {"manage", "create", "--uid", "1", + "--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`, + "--add-group", `{"schoolCorpId":"x"}`, "--source", "1"}, + {"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"}, + {"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明", "--mobile", "13900139000"}, + {"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "小明", + "--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`}, + {"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", + "--app-type", "XIAOTIANDI", "--open", "true"}, + {"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", + "--app-type", "LEARNING_VIDEO", "--open", "false"}, + } + for _, args := range cases { + t.Run(strings.Join(args, " "), func(t *testing.T) { + withEduFamilyGroupCaller(t) + cmd := newEduFamilyGroupCommand() + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute(%v) = %v, want nil", args, err) + } + }) + } +} + +func TestEduFamilyGroupErrorPaths(t *testing.T) { + cases := []struct { + name string + args []string + want string + }{ + {"check-exists missing uid", []string{"group", "check-exists", "--group-name", "x"}, "uid"}, + {"check-exists non-int uid", []string{"group", "check-exists", "--uid", "abc", "--group-name", "x"}, "整数"}, + {"check-exists missing group-name", []string{"group", "check-exists", "--uid", "1"}, "group-name"}, + {"list-children missing uid", []string{"group", "list-children"}, "uid"}, + {"create missing uid", []string{"manage", "create", "--children", "[]"}, "uid"}, + {"create missing children", []string{"manage", "create", "--uid", "1"}, "children"}, + {"create children bad json", []string{"manage", "create", "--uid", "1", "--children", "{"}, "JSON"}, + {"create children empty array", []string{"manage", "create", "--uid", "1", "--children", "[]"}, "不能为空数组"}, + {"create add-group bad json", []string{"manage", "create", "--uid", "1", + "--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`, + "--add-group", "{"}, "add-group"}, + {"create add-group null", []string{"manage", "create", "--uid", "1", + "--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`, + "--add-group", "null"}, "null"}, + {"invite-parent missing org-id", []string{"manage", "invite-parent", "--uid", "2", "--mobile", "138"}, "org-id"}, + {"invite-parent missing uid", []string{"manage", "invite-parent", "--org-id", "1", "--mobile", "138"}, "uid"}, + {"invite-parent missing mobile", []string{"manage", "invite-parent", "--org-id", "1", "--uid", "2"}, "mobile"}, + {"add-child missing org-id", []string{"manage", "add-child", "--uid", "2", "--name", "x", "--mobile", "138"}, "org-id"}, + {"add-child missing uid", []string{"manage", "add-child", "--org-id", "1", "--name", "x", "--mobile", "138"}, "uid"}, + {"add-child missing name", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--mobile", "138"}, "name"}, + {"add-child no mobile no students", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x"}, "至少传一个"}, + {"add-child students bad json", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "{"}, "students"}, + {"add-child students empty", []string{"manage", "add-child", "--org-id", "1", "--uid", "2", "--name", "x", "--students", "[]"}, "不能为空数组"}, + {"toggle-app missing org-id", []string{"manage", "toggle-app", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "org-id"}, + {"toggle-app missing uid", []string{"manage", "toggle-app", "--org-id", "1", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}, "uid"}, + {"toggle-app missing child-staff-id", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--app-type", "XIAOTIANDI", "--open", "true"}, "child-staff-id"}, + {"toggle-app missing app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--open", "true"}, "app-type"}, + {"toggle-app invalid app-type", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "OTHER", "--open", "true"}, "XIAOTIANDI"}, + {"toggle-app missing open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI"}, "open"}, + {"toggle-app invalid open", []string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "maybe"}, "true 或 false"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + withEduFamilyGroupCaller(t) + cmd := newEduFamilyGroupCommand() + cmd.SetArgs(tc.args) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Execute(%v) error = %v, want contains %q", tc.args, err, tc.want) + } + }) + } +} + +func TestEduFamilyGroupValidateChildren(t *testing.T) { + valid := []any{map[string]any{ + "name": "小明", + "students": []any{map[string]any{"corpId": "c", "staffId": "s"}}, + }} + if err := eduFamilyGroupValidateChildren(valid); err != nil { + t.Fatalf("valid children error = %v", err) + } + cases := []struct { + name string + children []any + want string + }{ + {"empty", []any{}, "不能为空数组"}, + {"non-object", []any{1}, "须为 JSON 对象"}, + {"missing name", []any{map[string]any{"students": []any{map[string]any{"corpId": "c", "staffId": "s"}}}}, "name 为必填"}, + {"missing students", []any{map[string]any{"name": "x"}}, "students 为必填"}, + {"student non-object", []any{map[string]any{"name": "x", "students": []any{1}}}, "须为 JSON 对象"}, + {"missing corpId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"staffId": "s"}}}}, "corpId 为必填"}, + {"missing staffId", []any{map[string]any{"name": "x", "students": []any{map[string]any{"corpId": "c"}}}}, "staffId 为必填"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := eduFamilyGroupValidateChildren(tc.children); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want contains %q", err, tc.want) + } + }) + } +} + +func TestEduFamilyGroupValidateStudents(t *testing.T) { + valid := []any{map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"}} + if err := eduFamilyGroupValidateStudents(valid); err != nil { + t.Fatalf("valid students error = %v", err) + } + cases := []struct { + name string + students []any + want string + }{ + {"empty", []any{}, "不能为空数组"}, + {"non-object", []any{1}, "须为 JSON 对象"}, + {"missing schoolOrgId", []any{map[string]any{"studentStaffId": "s"}}, "schoolOrgId 为必填"}, + {"missing studentStaffId", []any{map[string]any{"schoolOrgId": float64(1)}}, "studentStaffId 为必填"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := eduFamilyGroupValidateStudents(tc.students); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want contains %q", err, tc.want) + } + }) + } +} + +func withEduFamilyGroupDispatchCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{dryRun: false} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func TestEduFamilyGroupDispatch(t *testing.T) { + t.Run("check-exists", func(t *testing.T) { + caller := withEduFamilyGroupDispatchCaller(t) + cmd := newEduFamilyGroupCommand() + cmd.SetArgs([]string{"group", "check-exists", "--uid", "123", "--group-name", "测试家庭"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-familygroup" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup") + } + if caller.tool != "check_family_group_exists" { + t.Fatalf("tool = %q, want %q", caller.tool, "check_family_group_exists") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"]) + } + if input["uid"] != int64(123) { + t.Fatalf("input[\"uid\"] = %#v, want int64(123)", input["uid"]) + } + if input["groupName"] != "测试家庭" { + t.Fatalf("input[\"groupName\"] = %#v, want %q", input["groupName"], "测试家庭") + } + }) + + t.Run("list-children", func(t *testing.T) { + caller := withEduFamilyGroupDispatchCaller(t) + cmd := newEduFamilyGroupCommand() + cmd.SetArgs([]string{"group", "list-children", "--uid", "456"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-familygroup" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup") + } + if caller.tool != "listBoundChildren" { + t.Fatalf("tool = %q, want %q", caller.tool, "listBoundChildren") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"]) + } + if input["uid"] != int64(456) { + t.Fatalf("input[\"uid\"] = %#v, want int64(456)", input["uid"]) + } + }) + + t.Run("create", func(t *testing.T) { + caller := withEduFamilyGroupDispatchCaller(t) + cmd := newEduFamilyGroupCommand() + cmd.SetArgs([]string{"manage", "create", "--uid", "789", + "--children", `[{"name":"小明","students":[{"corpId":"c","staffId":"s"}]}]`}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-familygroup" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup") + } + if caller.tool != "create_family_group" { + t.Fatalf("tool = %q, want %q", caller.tool, "create_family_group") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"]) + } + if input["uid"] != int64(789) { + t.Fatalf("input[\"uid\"] = %#v, want int64(789)", input["uid"]) + } + if input["children"] == nil { + t.Fatalf("input[\"children\"] is nil, want non-nil") + } + }) + + t.Run("invite-parent", func(t *testing.T) { + caller := withEduFamilyGroupDispatchCaller(t) + cmd := newEduFamilyGroupCommand() + cmd.SetArgs([]string{"manage", "invite-parent", "--org-id", "1", "--uid", "2", "--mobile", "13800138000"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-familygroup" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup") + } + if caller.tool != "invite_parent_to_familygroup" { + t.Fatalf("tool = %q, want %q", caller.tool, "invite_parent_to_familygroup") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"]) + } + if input["orgId"] != int64(1) { + t.Fatalf("input[\"orgId\"] = %#v, want int64(1)", input["orgId"]) + } + if input["uid"] != int64(2) { + t.Fatalf("input[\"uid\"] = %#v, want int64(2)", input["uid"]) + } + if input["mobile"] != "13800138000" { + t.Fatalf("input[\"mobile\"] = %#v, want %q", input["mobile"], "13800138000") + } + }) + + t.Run("toggle-app", func(t *testing.T) { + caller := withEduFamilyGroupDispatchCaller(t) + cmd := newEduFamilyGroupCommand() + cmd.SetArgs([]string{"manage", "toggle-app", "--org-id", "1", "--uid", "2", + "--child-staff-id", "s", "--app-type", "XIAOTIANDI", "--open", "true"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-familygroup" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup") + } + if caller.tool != "toggle_student_app" { + t.Fatalf("tool = %q, want %q", caller.tool, "toggle_student_app") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] = %#v, want map[string]any", caller.args["input"]) + } + if input["appType"] != "XIAOTIANDI" { + t.Fatalf("input[\"appType\"] = %#v, want %q", input["appType"], "XIAOTIANDI") + } + if input["open"] != true { + t.Fatalf("input[\"open\"] = %#v, want true", input["open"]) + } + }) +} diff --git a/internal/helpers/edu_group.go b/internal/helpers/edu_group.go new file mode 100644 index 00000000..93d84317 --- /dev/null +++ b/internal/helpers/edu_group.go @@ -0,0 +1,846 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +// ────────────────────────────────────────────────────────── +// dws edu-group — 家校群管理 +// 共 14 个工具,按 student-group / class-group / batch 分组 +// ────────────────────────────────────────────────────────── + +func newEduGroupCommand() *cobra.Command { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: "edu-group", + Selection: contract.ProductSelectionDecl{ + AgentSummary: "师生群查询/创建/解散、班级群会话信息查询、批量操作", + UseWhen: []string{ + "用户要查询或管理钉钉师生群、班级群会话信息,或批量检查/创建师生群。", + }, + AvoidWhen: []string{ + "家庭群用 edu-familygroup;家校通讯录用 edu-contact;家校应用/作业/打卡用 edu-app。", + }, + }, + }) + root := &cobra.Command{ + Use: "edu-group", + Short: "家校群", + Long: `钉钉家校群管理:师生群查询/创建/解散、班级群会话信息查询、批量操作等。`, + Hidden: true, + RunE: groupRunE, + } + + // ════════════════════════════════════════════════════════════ + // student-group 子命令组 — 师生群管理 + // ════════════════════════════════════════════════════════════ + + studentGroupCmd := &cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE} + + studentGroupInfoCmd := &cobra.Command{ + Use: "info", + Short: "查询班级师生群信息", + Long: `查询指定班级的师生群信息。返回师生群的群会话ID(cid)。管理员、班主任、老师角色可调用。`, + Example: ` dws edu-group student-group info --dept-id 12345 + dws edu-group student-group info --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-group", "get_class_group_info", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(studentGroupInfoCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "get_class_group_info", + CanonicalPath: "edu-group.get_class_group_info", + CLIPath: "edu-group student-group info", + PrimaryCLIPath: "edu-group student-group info", + }, + Description: "查询班级师生群信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_group_info"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询班级师生群信息", + UseWhen: []string{"需要查询指定班级的师生群会话ID时"}, + AvoidWhen: []string{"查询班级群会话详情用 student-group conversation"}, + Examples: []string{ + "dws edu-group student-group info --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + studentGroupExistsCmd := &cobra.Command{ + Use: "exists", + Short: "检查组织是否已创建师生群", + Long: `检查指定组织下是否已创建师生群。返回是否存在师生群(true/false)。仅限管理员角色调用。`, + Example: ` dws edu-group student-group exists --dept-id 12345 + dws edu-group student-group exists --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-group", "check_class_group_exists", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(studentGroupExistsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "check_class_group_exists", + CanonicalPath: "edu-group.check_class_group_exists", + CLIPath: "edu-group student-group exists", + PrimaryCLIPath: "edu-group student-group exists", + }, + Description: "检查组织是否已创建师生群", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_group_exists"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "检查组织是否已创建师生群", + UseWhen: []string{"需要判断指定班级是否已创建师生群时"}, + AvoidWhen: []string{"查询师生群成员用 student-group members"}, + Examples: []string{ + "dws edu-group student-group exists --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + studentGroupMembersCmd := &cobra.Command{ + Use: "members", + Short: "查询师生群成员列表", + Long: `查询指定班级师生群的所有成员userId列表。管理员、班主任、老师角色可调用。`, + Example: ` dws edu-group student-group members --dept-id 12345 + dws edu-group student-group members --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-group", "get_group_members", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(studentGroupMembersCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "get_group_members", + CanonicalPath: "edu-group.get_group_members", + CLIPath: "edu-group student-group members", + PrimaryCLIPath: "edu-group student-group members", + }, + Description: "查询师生群成员列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_members"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询师生群成员列表", + UseWhen: []string{"需要查询指定班级师生群的所有成员userId列表时"}, + AvoidWhen: []string{"判断用户是否在群中用 student-group is-in"}, + Examples: []string{ + "dws edu-group student-group members --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + studentGroupIsInCmd := &cobra.Command{ + Use: "is-in", + Short: "判断用户是否在师生群中", + Long: `判断当前用户是否在指定班级的师生群中。返回是否在群中(true/false)。管理员、班主任、老师角色可调用。`, + Example: ` dws edu-group student-group is-in --dept-id 12345 + dws edu-group student-group is-in --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-group", "is_in_class_group", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(studentGroupIsInCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "is_in_class_group", + CanonicalPath: "edu-group.is_in_class_group", + CLIPath: "edu-group student-group is-in", + PrimaryCLIPath: "edu-group student-group is-in", + }, + Description: "判断用户是否在师生群中", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "is_in_class_group"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "判断用户是否在师生群中", + UseWhen: []string{"需要判断当前用户是否在指定班级师生群中时"}, + AvoidWhen: []string{"查询群成员列表用 student-group members"}, + Examples: []string{ + "dws edu-group student-group is-in --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + studentGroupConversationCmd := &cobra.Command{ + Use: "conversation", + Short: "查询班级群会话详情", + Long: `查询指定班级师生群的会话详情。 +返回群会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。 +管理员、班主任、老师角色可调用。`, + Example: ` dws edu-group student-group conversation --dept-id 12345 + dws edu-group student-group conversation --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-group", "get_group_conversation_info", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(studentGroupConversationCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "get_group_conversation_info", + CanonicalPath: "edu-group.get_group_conversation_info", + CLIPath: "edu-group student-group conversation", + PrimaryCLIPath: "edu-group student-group conversation", + }, + Description: "查询班级群会话详情", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_group_conversation_info"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询班级师生群会话详情", + UseWhen: []string{"需要查询指定班级师生群的会话ID、标题、成员数、图标时"}, + AvoidWhen: []string{"仅需群会话ID用 student-group info"}, + Examples: []string{ + "dws edu-group student-group conversation --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + studentGroupCreateCmd := &cobra.Command{ + Use: "create", + Short: "创建班级师生群", + Long: `为指定班级创建师生群。自动将班级的班主任设为群主,并拉入所有老师和学生。 +前提是班级必须已设置班主任。返回创建成功的群会话ID(cid)。仅限管理员或班主任角色调用。`, + Example: ` dws edu-group student-group create --dept-id 12345 + dws edu-group student-group create --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-group", "create_class_group", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(studentGroupCreateCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "create_class_group", + CanonicalPath: "edu-group.create_class_group", + CLIPath: "edu-group student-group create", + PrimaryCLIPath: "edu-group student-group create", + }, + Description: "创建班级师生群", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "create_class_group"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "为指定班级创建师生群", + UseWhen: []string{"需要为指定班级创建师生群,自动拉入班主任、老师和学生时"}, + AvoidWhen: []string{"批量创建师生群用 batch create-student-groups"}, + Examples: []string{ + "dws edu-group student-group create --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + studentGroupDisbandCmd := &cobra.Command{ + Use: "disband", + Short: "解散班级师生群", + Long: `解散指定班级的师生群,同时删除班级与群的关联关系。仅限管理员或班主任角色调用。`, + Example: ` dws edu-group student-group disband --dept-id 12345 + dws edu-group student-group disband --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-group", "disband_class_group", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(studentGroupDisbandCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "disband_class_group", + CanonicalPath: "edu-group.disband_class_group", + CLIPath: "edu-group student-group disband", + PrimaryCLIPath: "edu-group student-group disband", + }, + Description: "解散班级师生群", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "disband_class_group"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "解散班级师生群并删除关联关系", + UseWhen: []string{"需要解散指定班级的师生群并删除班级与群的关联关系时"}, + AvoidWhen: []string{"查询师生群信息用 student-group info"}, + Examples: []string{ + "dws edu-group student-group disband --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // class-group 子命令组 — 班级群(家校群)会话管理 + // ════════════════════════════════════════════════════════════ + + classGroupCmd := &cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE} + + classGroupConversationIDCmd := &cobra.Command{ + Use: "conversation-id", + Short: "获取班级群会话ID", + Long: `获取指定班级的班级群会话ID,可用于后续发送群消息等操作。管理员、班主任、老师角色可调用。`, + Example: ` dws edu-group class-group conversation-id --dept-id 12345 + dws edu-group class-group conversation-id --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-group", "get_class_conversation_id", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(classGroupConversationIDCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "get_class_conversation_id", + CanonicalPath: "edu-group.get_class_conversation_id", + CLIPath: "edu-group class-group conversation-id", + PrimaryCLIPath: "edu-group class-group conversation-id", + }, + Description: "获取班级群会话ID", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation_id"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "获取班级群会话ID", + UseWhen: []string{"需要获取指定班级的班级群会话ID以便后续发送群消息时"}, + AvoidWhen: []string{"需要完整群信息用 class-group conversation"}, + Examples: []string{ + "dws edu-group class-group conversation-id --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + classGroupConversationCmd := &cobra.Command{ + Use: "conversation", + Short: "获取班级群完整会话信息", + Long: `获取指定班级的班级群完整会话信息。 +返回班级群的会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。 +管理员、班主任、老师角色可调用。`, + Example: ` dws edu-group class-group conversation --dept-id 12345 + dws edu-group class-group conversation --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-group", "get_class_conversation", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(classGroupConversationCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "get_class_conversation", + CanonicalPath: "edu-group.get_class_conversation", + CLIPath: "edu-group class-group conversation", + PrimaryCLIPath: "edu-group class-group conversation", + }, + Description: "获取班级群完整会话信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "get_class_conversation"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "获取班级群完整会话信息", + UseWhen: []string{"需要查询指定班级的班级群会话ID、标题、成员数、图标时"}, + AvoidWhen: []string{"仅需会话ID用 class-group conversation-id"}, + Examples: []string{ + "dws edu-group class-group conversation --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + classGroupExistsCmd := &cobra.Command{ + Use: "exists", + Short: "检查班级群是否存在", + Long: `检查指定班级是否已创建班级群。返回班级群是否存在(true/false)。管理员、班主任、老师角色可调用。`, + Example: ` dws edu-group class-group exists --dept-id 12345 + dws edu-group class-group exists --dept-id 12345 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + deptID, err := eduGroupRequiredIntFlag(cmd, "dept-id") + if err != nil { + return err + } + return callMCPToolOnServer("edu-group", "check_class_conversation_exists", map[string]any{ + "input": map[string]any{"deptId": deptID}, + }) + }, + } + DeclareLeafMetadata(classGroupExistsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "check_class_conversation_exists", + CanonicalPath: "edu-group.check_class_conversation_exists", + CLIPath: "edu-group class-group exists", + PrimaryCLIPath: "edu-group class-group exists", + }, + Description: "检查班级群是否存在", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "check_class_conversation_exists"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "检查班级群是否存在", + UseWhen: []string{"需要判断指定班级是否已创建班级群时"}, + AvoidWhen: []string{"查询班级群会话信息用 class-group conversation"}, + Examples: []string{ + "dws edu-group class-group exists --dept-id 12345 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, + }, + }, + }) + + classGroupListByConversationIDsCmd := &cobra.Command{ + Use: "list-by-cids", + Short: "根据会话ID列表批量查询群信息", + Long: `根据群会话ID列表批量查询群的详细信息。 +返回群会话详情列表,每项包含会话ID(cid)、群标题(title)、群成员数量(memberCount)和群图标URL(icon)。 +管理员、班主任、老师角色可调用。`, + Example: ` dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3 + dws edu-group class-group list-by-cids --conversation-ids cid1,cid2 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + raw, _ := cmd.Flags().GetString("conversation-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--conversation-ids 为必填参数") + } + conversationIDs := eduGroupParseCSV(raw) + if len(conversationIDs) == 0 { + return fmt.Errorf("--conversation-ids 不能为空") + } + return callMCPToolOnServer("edu-group", "list_groups_by_conversation_ids", map[string]any{ + "input": map[string]any{"conversationIds": conversationIDs}, + }) + }, + } + DeclareLeafMetadata(classGroupListByConversationIDsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "list_groups_by_conversation_ids", + CanonicalPath: "edu-group.list_groups_by_conversation_ids", + CLIPath: "edu-group class-group list-by-cids", + PrimaryCLIPath: "edu-group class-group list-by-cids", + }, + Description: "根据会话ID列表批量查询群信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "list_groups_by_conversation_ids"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "根据会话ID列表批量查询群信息", + UseWhen: []string{"需要根据一组群会话ID批量查询群的详细信息时"}, + AvoidWhen: []string{"按班级ID批量查询用 batch get-class-groups"}, + Examples: []string{ + "dws edu-group class-group list-by-cids --conversation-ids cid1,cid2,cid3 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "conversation-ids", Property: "input.conversationIds", Required: boolPtr(true)}, + }, + }, + }) + + // ════════════════════════════════════════════════════════════ + // batch 子命令组 — 批量操作 + // ════════════════════════════════════════════════════════════ + + batchCmd := &cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE} + + batchCheckClassGroupCmd := &cobra.Command{ + Use: "check-student-group", + Short: "批量检查班级是否已创建师生群", + Long: `批量检查多个班级是否已创建师生群。返回班级ID与群会话ID(cid)的映射关系。仅限管理员角色调用。`, + Example: ` dws edu-group batch check-student-group --class-ids 12345,67890 + dws edu-group batch check-student-group --class-ids 12345,67890 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + raw, _ := cmd.Flags().GetString("class-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--class-ids 为必填参数") + } + classIDs, err := eduGroupParseIntCSV(raw) + if err != nil { + return fmt.Errorf("--class-ids 须为逗号分隔的整数列表: %w", err) + } + return callMCPToolOnServer("edu-group", "batch_check_class_group", map[string]any{ + "input": map[string]any{"classIds": classIDs}, + }) + }, + } + DeclareLeafMetadata(batchCheckClassGroupCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "batch_check_class_group", + CanonicalPath: "edu-group.batch_check_class_group", + CLIPath: "edu-group batch check-student-group", + PrimaryCLIPath: "edu-group batch check-student-group", + }, + Description: "批量检查班级是否已创建师生群", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_check_class_group"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "批量检查班级是否已创建师生群", + UseWhen: []string{"需要批量检查多个班级是否已创建师生群时"}, + AvoidWhen: []string{"单个班级检查用 student-group exists"}, + Examples: []string{ + "dws edu-group batch check-student-group --class-ids 12345,67890 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)}, + }, + }, + }) + + batchGetClassConversationsCmd := &cobra.Command{ + Use: "get-class-groups", + Short: "批量获取班级群信息", + Long: `批量获取多个班级的班级群会话信息。返回班级ID与群会话信息的映射关系。仅限管理员角色调用。`, + Example: ` dws edu-group batch get-class-groups --class-ids 12345,67890 + dws edu-group batch get-class-groups --class-ids 12345,67890 -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + raw, _ := cmd.Flags().GetString("class-ids") + raw = strings.TrimSpace(raw) + if raw == "" { + return fmt.Errorf("--class-ids 为必填参数") + } + classIDs := eduGroupParseCSV(raw) + if len(classIDs) == 0 { + return fmt.Errorf("--class-ids 不能为空") + } + return callMCPToolOnServer("edu-group", "batch_get_class_conversations", map[string]any{ + "input": map[string]any{"classIds": classIDs}, + }) + }, + } + DeclareLeafMetadata(batchGetClassConversationsCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "batch_get_class_conversations", + CanonicalPath: "edu-group.batch_get_class_conversations", + CLIPath: "edu-group batch get-class-groups", + PrimaryCLIPath: "edu-group batch get-class-groups", + }, + Description: "批量获取班级群信息", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_get_class_conversations"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "批量获取班级群会话信息", + UseWhen: []string{"需要批量获取多个班级的班级群会话信息时"}, + AvoidWhen: []string{"按会话ID批量查询用 class-group list-by-cids"}, + Examples: []string{ + "dws edu-group batch get-class-groups --class-ids 12345,67890 --format json", + }, + }, + Parameters: []contract.ParamDecl{ + {Name: "class-ids", Property: "input.classIds", Required: boolPtr(true)}, + }, + }, + }) + + batchCreateClassGroupCmd := &cobra.Command{ + Use: "create-student-groups", + Short: "批量创建师生群", + Long: `为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群。 +小学和幼儿园学段的班级不会创建师生群。仅限管理员角色调用。`, + Example: ` dws edu-group batch create-student-groups + dws edu-group batch create-student-groups -f json`, + RunE: func(cmd *cobra.Command, args []string) error { + return callMCPToolOnServer("edu-group", "batch_create_class_group", map[string]any{ + "input": map[string]any{}, + }) + }, + } + DeclareLeafMetadata(batchCreateClassGroupCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "not_required", Idempotency: "non_idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "edu-group", + Name: "batch_create_class_group", + CanonicalPath: "edu-group.batch_create_class_group", + CLIPath: "edu-group batch create-student-groups", + PrimaryCLIPath: "edu-group batch create-student-groups", + }, + Description: "批量创建师生群", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "edu-group", RPCName: "batch_create_class_group"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "为组织下符合条件的班级批量创建师生群", + UseWhen: []string{"需要为组织下所有已设置班主任但尚未创建师生群的班级批量创建师生群时"}, + AvoidWhen: []string{"单个班级创建用 student-group create"}, + Examples: []string{ + "dws edu-group batch create-student-groups --format json", + }, + }, + Parameters: []contract.ParamDecl{}, + }, + }) + + // ════════════════════════════════════════════════════════════ + // flags + 构建命令树 + // ════════════════════════════════════════════════════════════ + + // student-group flags + studentGroupInfoCmd.Flags().String("dept-id", "", "班级 ID(必填)") + studentGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)") + studentGroupMembersCmd.Flags().String("dept-id", "", "班级 ID(必填)") + studentGroupIsInCmd.Flags().String("dept-id", "", "班级 ID(必填)") + studentGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)") + studentGroupCreateCmd.Flags().String("dept-id", "", "班级 ID(必填)") + studentGroupDisbandCmd.Flags().String("dept-id", "", "班级 ID(必填)") + + // class-group flags + classGroupConversationIDCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classGroupConversationCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classGroupExistsCmd.Flags().String("dept-id", "", "班级 ID(必填)") + classGroupListByConversationIDsCmd.Flags().String("conversation-ids", "", "群会话 ID 列表,逗号分隔(必填)") + + // batch flags + batchCheckClassGroupCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)") + batchGetClassConversationsCmd.Flags().String("class-ids", "", "班级 ID 列表,逗号分隔(必填)") + + studentGroupCmd.AddCommand( + studentGroupInfoCmd, studentGroupExistsCmd, studentGroupMembersCmd, + studentGroupIsInCmd, studentGroupConversationCmd, + studentGroupCreateCmd, studentGroupDisbandCmd, + ) + classGroupCmd.AddCommand( + classGroupConversationIDCmd, classGroupConversationCmd, + classGroupExistsCmd, classGroupListByConversationIDsCmd, + ) + batchCmd.AddCommand(batchCheckClassGroupCmd, batchGetClassConversationsCmd, batchCreateClassGroupCmd) + + root.AddCommand(studentGroupCmd, classGroupCmd, batchCmd) + + return root +} + +// eduGroupRequiredIntFlag extracts a required integer flag, returning an error +// if the flag is empty or not a valid integer. +func eduGroupRequiredIntFlag(cmd *cobra.Command, name string) (int64, error) { + v, _ := cmd.Flags().GetString(name) + v = strings.TrimSpace(v) + if v == "" { + return 0, fmt.Errorf("--%s 为必填参数", name) + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("--%s 须为整数: %w", name, err) + } + return n, nil +} + +// eduGroupParseCSV splits a comma-separated string into trimmed non-empty values. +func eduGroupParseCSV(raw string) []string { + parts := strings.Split(raw, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + v := strings.TrimSpace(p) + if v != "" { + result = append(result, v) + } + } + return result +} + +// eduGroupParseIntCSV splits a comma-separated string into int64 values. +func eduGroupParseIntCSV(raw string) ([]int64, error) { + parts := strings.Split(raw, ",") + result := make([]int64, 0, len(parts)) + for _, p := range parts { + v := strings.TrimSpace(p) + if v == "" { + continue + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid integer %q", v) + } + result = append(result, n) + } + return result, nil +} diff --git a/internal/helpers/edu_group_test.go b/internal/helpers/edu_group_test.go new file mode 100644 index 00000000..c8f92095 --- /dev/null +++ b/internal/helpers/edu_group_test.go @@ -0,0 +1,281 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "io" + "strings" + "testing" +) + +// withEduGroupCaller installs a dry-run capture caller so happy-path command +// execution exercises each RunE up to the callMCPToolOnServer dispatch without +// requiring a live MCP transport. +func withEduGroupCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{dryRun: true} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func TestEduGroupHappyPaths(t *testing.T) { + cases := [][]string{ + {"student-group", "info", "--dept-id", "123"}, + {"student-group", "exists", "--dept-id", "123"}, + {"student-group", "members", "--dept-id", "123"}, + {"student-group", "is-in", "--dept-id", "123"}, + {"student-group", "conversation", "--dept-id", "123"}, + {"student-group", "create", "--dept-id", "123"}, + {"student-group", "disband", "--dept-id", "123"}, + {"class-group", "conversation-id", "--dept-id", "123"}, + {"class-group", "conversation", "--dept-id", "123"}, + {"class-group", "exists", "--dept-id", "123"}, + {"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2"}, + {"batch", "check-student-group", "--class-ids", "1,2"}, + {"batch", "get-class-groups", "--class-ids", "1,2"}, + {"batch", "create-student-groups"}, + } + for _, args := range cases { + t.Run(strings.Join(args, " "), func(t *testing.T) { + withEduGroupCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute(%v) = %v, want nil", args, err) + } + }) + } +} + +func TestEduGroupMissingRequiredFlags(t *testing.T) { + // Each dept-id command must reject an absent --dept-id, covering its own + // error branch as well as the shared eduGroupRequiredIntFlag empty case. + deptIDCommands := [][]string{ + {"student-group", "info"}, + {"student-group", "exists"}, + {"student-group", "members"}, + {"student-group", "is-in"}, + {"student-group", "conversation"}, + {"student-group", "create"}, + {"student-group", "disband"}, + {"class-group", "conversation-id"}, + {"class-group", "conversation"}, + {"class-group", "exists"}, + } + for _, args := range deptIDCommands { + t.Run(strings.Join(args, " "), func(t *testing.T) { + withEduGroupCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs(args) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "dept-id") { + t.Fatalf("Execute(%v) error = %v, want dept-id required", args, err) + } + }) + } +} + +func TestEduGroupFlagValidation(t *testing.T) { + t.Run("non-integer dept-id", func(t *testing.T) { + withEduGroupCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"student-group", "info", "--dept-id", "abc"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") { + t.Fatalf("non-integer dept-id error = %v", err) + } + }) + + t.Run("list-by-cids missing conversation-ids", func(t *testing.T) { + withEduGroupCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"class-group", "list-by-cids"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") { + t.Fatalf("missing conversation-ids error = %v", err) + } + }) + + t.Run("list-by-cids only separators", func(t *testing.T) { + withEduGroupCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", " , , "}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "conversation-ids") { + t.Fatalf("empty conversation-ids error = %v", err) + } + }) + + t.Run("batch check missing class-ids", func(t *testing.T) { + withEduGroupCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"batch", "check-student-group"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") { + t.Fatalf("missing class-ids error = %v", err) + } + }) + + t.Run("batch check invalid integer class-ids", func(t *testing.T) { + withEduGroupCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "x"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "整数") { + t.Fatalf("invalid integer class-ids error = %v", err) + } + }) + + t.Run("batch get missing class-ids", func(t *testing.T) { + withEduGroupCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"batch", "get-class-groups"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") { + t.Fatalf("missing class-ids error = %v", err) + } + }) + + t.Run("batch get only separators", func(t *testing.T) { + withEduGroupCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"batch", "get-class-groups", "--class-ids", " , , "}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "class-ids") { + t.Fatalf("empty class-ids error = %v", err) + } + }) +} + +func TestEduGroupParseHelpers(t *testing.T) { + if got := eduGroupParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" { + t.Fatalf("eduGroupParseCSV = %#v", got) + } + ids, err := eduGroupParseIntCSV(" 1 , , 2 ") + if err != nil || len(ids) != 2 || ids[0] != 1 || ids[1] != 2 { + t.Fatalf("eduGroupParseIntCSV = %#v, err = %v", ids, err) + } + if _, err := eduGroupParseIntCSV("1,bad"); err == nil { + t.Fatalf("eduGroupParseIntCSV invalid = nil error") + } +} + +// withEduGroupDispatchCaller installs a non-dry-run capture caller so commands +// exercise the full dispatch path through deps.Caller.CallTool. +func withEduGroupDispatchCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func TestEduGroupDispatch(t *testing.T) { + t.Run("student-group info dispatches get_class_group_info", func(t *testing.T) { + caller := withEduGroupDispatchCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"student-group", "info", "--dept-id", "123"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute error = %v", err) + } + if caller.productID != "edu-group" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-group") + } + if caller.tool != "get_class_group_info" { + t.Fatalf("tool = %q, want %q", caller.tool, "get_class_group_info") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"]) + } + if input["deptId"] != int64(123) { + t.Fatalf("input[\"deptId\"] = %#v, want int64(123)", input["deptId"]) + } + }) + + t.Run("student-group create dispatches create_class_group", func(t *testing.T) { + caller := withEduGroupDispatchCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"student-group", "create", "--dept-id", "456"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute error = %v", err) + } + if caller.productID != "edu-group" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-group") + } + if caller.tool != "create_class_group" { + t.Fatalf("tool = %q, want %q", caller.tool, "create_class_group") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"]) + } + if input["deptId"] != int64(456) { + t.Fatalf("input[\"deptId\"] = %#v, want int64(456)", input["deptId"]) + } + }) + + t.Run("student-group disband dispatches disband_class_group", func(t *testing.T) { + caller := withEduGroupDispatchCaller(t) + cmd := newEduGroupCommand() + cmd.PersistentFlags().Bool("yes", false, "") + cmd.PersistentFlags().Bool("dry-run", false, "") + cmd.SetArgs([]string{"student-group", "disband", "--dept-id", "789", "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute error = %v", err) + } + if caller.productID != "edu-group" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-group") + } + if caller.tool != "disband_class_group" { + t.Fatalf("tool = %q, want %q", caller.tool, "disband_class_group") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"]) + } + if input["deptId"] != int64(789) { + t.Fatalf("input[\"deptId\"] = %#v, want int64(789)", input["deptId"]) + } + }) + + t.Run("class-group list-by-cids dispatches list_groups_by_conversation_ids", func(t *testing.T) { + caller := withEduGroupDispatchCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute error = %v", err) + } + if caller.productID != "edu-group" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-group") + } + if caller.tool != "list_groups_by_conversation_ids" { + t.Fatalf("tool = %q, want %q", caller.tool, "list_groups_by_conversation_ids") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"]) + } + cids, ok := input["conversationIds"].([]string) + if !ok || len(cids) != 3 || cids[0] != "cid1" || cids[1] != "cid2" || cids[2] != "cid3" { + t.Fatalf("input[\"conversationIds\"] = %#v, want [cid1 cid2 cid3]", input["conversationIds"]) + } + }) + + t.Run("batch check-student-group dispatches batch_check_class_group", func(t *testing.T) { + caller := withEduGroupDispatchCaller(t) + cmd := newEduGroupCommand() + cmd.SetArgs([]string{"batch", "check-student-group", "--class-ids", "100,200"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute error = %v", err) + } + if caller.productID != "edu-group" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-group") + } + if caller.tool != "batch_check_class_group" { + t.Fatalf("tool = %q, want %q", caller.tool, "batch_check_class_group") + } + input, ok := caller.args["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] = %#v, want map", caller.args["input"]) + } + classIDs, ok := input["classIds"].([]int64) + if !ok || len(classIDs) != 2 || classIDs[0] != 100 || classIDs[1] != 200 { + t.Fatalf("input[\"classIds\"] = %#v, want [100 200]", input["classIds"]) + } + }) +} diff --git a/internal/helpers/register_edu.go b/internal/helpers/register_edu.go new file mode 100644 index 00000000..f90f710f --- /dev/null +++ b/internal/helpers/register_edu.go @@ -0,0 +1,28 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import "github.com/spf13/cobra" + +// 家校/高校系列(家校通讯录、家校群、家校应用、家庭群、高校通讯录)是开源库 +// 显式维护的公开命令,不依赖生成式产品注册表(register_products.go)。 +// 其 MCP server 端点由运行时宿主按 serverID 解析。 +func init() { + eduCommands := []struct { + name string + buildFn func() *cobra.Command + }{ + {"edu-contact", newEduContactCommand}, + {"edu-group", newEduGroupCommand}, + {"edu-app", newEduAppCommand}, + {"edu-familygroup", newEduFamilyGroupCommand}, + {"college-contact", newCollegeContactCommand}, + } + for _, c := range eduCommands { + c := c + RegisterPublic(func() Handler { + return wukongHandler{name: c.name, buildFn: c.buildFn} + }) + } +} diff --git a/pkg/edition/default.go b/pkg/edition/default.go index 04e92642..4ca77c9b 100644 --- a/pkg/edition/default.go +++ b/pkg/edition/default.go @@ -66,6 +66,36 @@ func openSupplementServers() []ServerInfo { Endpoint: "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff", Prefixes: []string{"recruit", "job"}, }, + { + ID: "edu-contact", + Name: "家校通讯录", + Endpoint: "https://mcp-gw.dingtalk.com/server/d24759cc1c6e424e2de4e9901ea0202136e6707991ffc33b473878ec1cd688a2", + Prefixes: []string{"edu-contact", "edu"}, + }, + { + ID: "edu-group", + Name: "家校群", + Endpoint: "https://mcp-gw.dingtalk.com/server/14624b71ac9bc1a03b1b60e5b0403a48b346361f86cc9f555f98f89eb383875a", + Prefixes: []string{"edu-group"}, + }, + { + ID: "edu-app", + Name: "家校应用", + Endpoint: "https://mcp-gw.dingtalk.com/server/905eef591d16e2a1d95b235bcc780ce2fadb6ebe1f25648a279f8a2d97907a1e", + Prefixes: []string{"edu-app"}, + }, + { + ID: "edu-familygroup", + Name: "家庭群", + Endpoint: "https://mcp-gw.dingtalk.com/server/1cd153fb5296df340507c3e9ee20c938f9feeefec3147e9cc32317032f1a2944", + Prefixes: []string{"edu-familygroup"}, + }, + { + ID: "college-contact", + Name: "高校通讯录", + Endpoint: "https://mcp-gw.dingtalk.com/server/45bb310b388b9c39e0b80e08236782880cb51ad536e1292f9a40933c428a7474", + Prefixes: []string{"college-contact"}, + }, } } diff --git a/skills/content-qa/mono-multi-coverage.yaml b/skills/content-qa/mono-multi-coverage.yaml index 8dc8895a..2e471ad3 100644 --- a/skills/content-qa/mono-multi-coverage.yaml +++ b/skills/content-qa/mono-multi-coverage.yaml @@ -112,6 +112,26 @@ coverage: multi_skill: dingtalk-misc multi_refs: - references/recruit.md + - mono: edu-app + multi_skill: dingtalk-misc + multi_refs: + - references/edu-app.md + - mono: edu-contact + multi_skill: dingtalk-misc + multi_refs: + - references/edu-contact.md + - mono: edu-group + multi_skill: dingtalk-misc + multi_refs: + - references/edu-group.md + - mono: college-contact + multi_skill: dingtalk-misc + multi_refs: + - references/college-contact.md + - mono: edu-familygroup + multi_skill: dingtalk-misc + multi_refs: + - references/edu-familygroup.md omit_coverage: - mono: simple diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 0ad95f22..7670952a 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -97,6 +97,11 @@ cli_version: ">=1.0.15" | `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) | | `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) | | `recruit` | 钉钉招聘:查询职位列表、获取职位详情、创建职位 | [recruit.md](./references/products/recruit.md) | +| `edu-app` | 家校应用(教育版):家校任务、班级消息摘要等教育场景应用能力 | [edu-app.md](./references/products/edu-app.md) | +| `edu-contact` | 家校通讯录(教育版):学校组织架构、班级列表等教育场景通讯录能力 | [edu-contact.md](./references/products/edu-contact.md) | +| `edu-group` | 家校群(师生群):班级群/师生群的查询、创建、解散 | [edu-group.md](./references/products/edu-group.md) | +| `edu-familygroup` | 家庭群:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制 | [edu-familygroup.md](./references/products/edu-familygroup.md) | +| `college-contact` | 高校通讯录:高校组织架构/院系部门管理(查询/创建/更新/删除)/师生员工管理(查询/添加/移除/变更类型与部门/激活短信)/通讯录搜索/概览统计/升级 | [college-contact.md](./references/products/college-contact.md) | | `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) | ## 意图判断决策树 @@ -166,6 +171,8 @@ cli_version: ">=1.0.15" | `oa` | `approval reject` | 拒绝待审批(需加明确理由) | | `todo` | `task delete` | 删除待办 | | `minutes` | `replace-text` | 全文批量替换转写与摘要 | +| `college-contact` | `dept delete` | 删除高校部门,不可恢复 | +| `college-contact` | `employee remove` | 移除高校员工,不可恢复 | ### 确认流程 ``` diff --git a/skills/mono/references/products/college-contact.md b/skills/mono/references/products/college-contact.md new file mode 100644 index 00000000..45622934 --- /dev/null +++ b/skills/mono/references/products/college-contact.md @@ -0,0 +1,135 @@ +# 高校通讯录 (college-contact) 命令参考 + +## 命令总览 + +### dept (部门管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `dept get-standard-structure` | 查询高校标准架构信息(组织 ID/行政架构部门 ID 映射) | 无 | +| `dept get-detail` | 查询部门详情 | `--dept-id` | +| `dept get-chain` | 查询部门链(根节点到当前部门) | `--dept-id` | +| `dept search` | 按关键词搜索通讯录(人员/部门/角色) | `--dept-id`, `--keyword` | +| `dept create` | 创建部门 | `--super-id`, `--stru-dept-id`, `--name`, `--dept-type`, `--create-dept-group` | +| `dept update` | 更新部门 | `--dept-id`, `--dept-type` | +| `dept delete` | 删除部门 ⚠️ | `--dept-id` | +| `dept batch-update-type` | 批量修改部门类型 | `--dept-ids`(逗号分隔), `--target-dept-type` | +| `dept overview` | 查询高校概览统计 | 无 | + +### employee (员工管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `employee get-detail` | 查询员工详情 | `--staff-id` | +| `employee add` | 添加员工(返回成功/失败数量及邮箱初始密码) | `--emp-type`, `--main-dept-id`, `--exclusive-account` | +| `employee remove` | 移除员工 ⚠️ | `--staff-ids`(逗号分隔) | +| `employee change-type` | 变更员工类型 | `--staff-id`, `--emp-type` | +| `employee change-dept` | 变更员工部门 | `--staff-id`, `--target-dept-id` | +| `employee send-active-sms` | 发送激活短信 | `--dept-id` | +| `employee list-employees` | 查询部门员工列表 | `--dept-id` | +| `employee list-unaccepted` | 查询未接受邀请的员工列表 | `--dept-id` | +| `employee list-unactive` | 查询未激活的员工列表 | `--dept-id` | +| `employee upgrade-status` | 查询高校通讯录升级状态 | 无 | +| `employee start-upgrade` | 启动高校通讯录升级 | 无 | + +### alumni (校友管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `alumni get-dept-tree` | 查询校友部门树 | `--alumni-dept-id` | +| `alumni get-info` | 查询校友部门详情 | `--alumni-dept-id` | +| `alumni list` | 查询校友列表 | `--alumni-dept-id`, `--order-field`, `--ordering` | +| `alumni query` | 查询单个校友详情 | `--staff-id` | +| `alumni search` | 搜索校友 | `--keyword` | +| `alumni list-unaccepted` | 查询未接受邀请的校友列表 | `--alumni-dept-id` | +| `alumni get-group` | 查询校友群信息 | `--alumni-dept-id` | +| `alumni create-dept` | 创建校友子部门 | `--alumni-dept-id`, `--dept-name` | +| `alumni update-dept` | 更新校友部门名称 | `--alumni-dept-id`, `--dept-name` | +| `alumni delete-dept` | 删除校友部门 ⚠️ | `--alumni-dept-id` | +| `alumni update-managers` | 设置校友部门负责人 | `--alumni-dept-id`, `--admin-user-ids`(逗号分隔) | +| `alumni add-alumnus` | 添加校友 | `--name`, `--mobile`, `--dept-ids`(逗号分隔) | +| `alumni update-alumnus` | 更新校友信息 | `--staff-id`, `--name`, `--dept-ids`(逗号分隔) | +| `alumni remove-alumnus` | 删除校友 ⚠️ | `--staff-id`, `--alumni-dept-id` | +| `alumni cancel-invite` | 取消校友邀请 ⚠️ | `--alumni-dept-id`, `--staff-ids`(逗号分隔) | +| `alumni create-group` | 创建校友群 | `--alumni-dept-id` | +| `alumni disband-group` | 解散校友群 ⚠️ | `--alumni-dept-id` | +| `alumni get-alumni-org-from-graduate` | 查询毕业生校友组织 | 无入参 | +| `alumni create-alumni-org` | 创建校友会组织 | `--org-name` | +| `alumni add-alumni-org-main-admins` | 添加校友会组织管理员 | `--admin-user-ids`(逗号分隔) | + +### graduate (毕业年级管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `graduate query-graduate-years` | 查询毕业年级列表 | 无入参 | +| `graduate query-graduate-depts` | 查询待毕业部门列表 | `--dept-id`, `--graduate-year`(可选) | +| `graduate query-graduate-sub-depts` | 查询毕业子部门列表 | `--dept-id` | +| `graduate query-page-graduate-users` | 分页查询待毕业学生列表 | `--dept-id`, `--graduate-year`/`--offset`/`--size`(可选) | +| `graduate get-task-result` | 查询异步任务执行结果 | `--request-no`, `--type`(可选) | +| `graduate get-alumni-org` | 查询校友组织信息 | 无入参 | +| `graduate query-restore-sub-depts` | 查询可恢复子部门列表 | `--dept-id` | +| `graduate query-dept-deleted-emps` | 查询部门可恢复员工列表 | `--dept-id`, `--offset`/`--size`(可选) | +| `graduate search-graduate` | 搜索毕业部门与员工 | `--keyword`, `--offset`/`--size`(可选) | +| `graduate commit-graduate` | 提交毕业 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--graduate-year`, `--request-no`(可选) | +| `graduate all-graduate` | 全部毕业 ⚠️ | `--graduate-year`, `--request-no`(可选) | +| `graduate batch-graduate` | 批量毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) | +| `graduate delete-and-graduate` | 删除并毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) | +| `graduate batch-delete-pending` | 批量删除待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) | +| `graduate batch-update-pending` | 批量更新待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔), `--graduate-year` | +| `graduate commit-restore` | 提交恢复 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--request-no`(可选) | + +### group (规则管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `group query-group-rule` | 查询规则 | `--name`(可选), `--offset`(可选), `--size`(可选) | +| `group get-group-rule-schedule` | 查询规则调度 | 无参数 | +| `group query-preview-data` | 查询规则预览数据 | `--offset`(可选), `--size`(可选) | +| `group create-group-rule` | 创建规则 | `--name`, `--tag-code`, `--dept-type`, `--auto-admin`(可选,true/false) | +| `group delete-group-rule` | 删除规则 ⚠️ | `--rule-id` | +| `group enable-group-rule` | 启用规则 | `--rule-id` | +| `group disable-group-rule` | 停用规则 | `--rule-id` | +| `group set-group-rule-schedule` | 设置规则调度 | `--cron`(可选) | +| `group execute-group-rule` | 立即执行规则 ⚠️ | 无参数 | + +## 常用参数说明 + +- `--emp-type`:员工类型,取值 `college_student`(学生)/ `college_teacher`(教职工) +- `--dept-type`:部门类型(如 `contact_grade_dept` 年级 / `contact_class_dept` 班级 / `contact_major_dept` 专业) +- `--staff-id` 单个员工 staffId;`--staff-ids` 为逗号分隔的批量列表 +- 列表类命令支持 `--offset` / `--size` 分页与 `--order-field` / `--ordering`(asc/desc) 排序 +- `--exclusive-account`、`--create-dept-group`、`--send-active-sms` 为布尔参数(true/false) + +## 意图判断 + +用户说"高校架构/组织架构/学院/系/部门" → dept 子命令 +用户说"搜人/找某某老师/找某某同学" → `dept search` +用户说"师生/教职工/学生/员工/辅导员" → employee 子命令 +用户说"激活/邀请/未激活账号" → `employee list-unactive` / `list-unaccepted` / `send-active-sms` +用户说"通讯录升级" → `employee upgrade-status` / `start-upgrade` +用户说"校友/校友会/校友部门/添加校友" → alumni 子命令 +用户说"毕业年级/毕业年份/待毕业学生/毕业操作" → graduate 子命令 +用户说"群规则/建群规则/自动建群" → group 子命令 + +## 核心工作流 + +1. 查标准架构 → `dept get-standard-structure`(提取 deptId) +2. 查看部门详情 → `dept get-detail --dept-id ` +3. 查看部门员工 → `employee list-employees --dept-id ` +4. 查看员工详情 → `employee get-detail --staff-id ` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `dept get-standard-structure` | `deptId` | dept/employee 子命令的 --dept-id | +| `employee list-employees` | `staffId` | get-detail/change-type/change-dept 的 --staff-id、remove 的 --staff-ids | +| `dept create` | `deptId` | update/delete 的 --dept-id | +| `alumni get-dept-tree` | `alumniDeptId` | alumni 子命令的 --alumni-dept-id | +| `alumni list` | `staffId` | update-alumnus/remove-alumnus 的 --staff-id | +| `graduate query-graduate-depts` | `deptId` | graduate 子命令的 --dept-id | +| `group query-group-rule` | `ruleId` | delete/enable/disable-group-rule 的 --rule-id | + +## 危险操作 + +- `dept delete`、`employee remove`、`alumni delete-dept`、`alumni remove-alumnus`、`alumni cancel-invite`、`alumni disband-group`、`graduate commit-graduate`、`graduate all-graduate`、`graduate batch-graduate`、`graduate delete-and-graduate`、`graduate batch-delete-pending`、`graduate batch-update-pending`、`graduate commit-restore`、`group delete-group-rule`、`group execute-group-rule` 不可逆:非 --dry-run 预览时必须显式传入 --yes 才会真实执行,未传 --yes 会直接拒绝。执行前必须向用户展示操作摘要并获得明确同意,确认后再追加 --yes。 diff --git a/skills/mono/references/products/edu-app.md b/skills/mono/references/products/edu-app.md new file mode 100644 index 00000000..22349119 --- /dev/null +++ b/skills/mono/references/products/edu-app.md @@ -0,0 +1,109 @@ +# 家校应用 (edu-app) 命令参考 + +## 命令总览 + +### message (消息管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `message summary-list` | 查询消息摘要列表 | `--class-id`, `--cid`, `--target-role`, `--status` | + +> `--target-role`: guardian(家长) / student(学生) +> `--status`: 0(未处理) / 1(已处理) + +### task (任务管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `task publish-list` | 查询发布的家校任务列表(仅老师) | 无(均可选) | +| `task all-list` | 查询全部家校任务列表(仅老师) | `--biz-id`(班级ID) | +| `task student-list` | 查询学生待办任务列表 | `--students`(JSON数组) | + +> `--task-sources` 可选值(逗号分隔): EDU_HOMEWORK, EDU_CARD, EDU_NOTICE, EDU_SR, EDU_DIPLOMA + +### report (成绩单管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `report get` | 获取成绩单列表 | `--ids`(逗号分隔整数) | +| `report by-teacher` | 查询老师创建的成绩单 | 无(均可选) | +| `report by-class` | 查询班级学生成绩明细 | `--report-id`, `--class-id` | +| `report by-student-list` | 查询学生收到的成绩单 | `--class-id`, `--student-id` | +| `report by-student-detail` | 查询学生成绩明细 | `--report-id`, `--student-id`, `--class-id` | + +### notice (通知管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `notice confirm` | 确认收到通知 | `--notice-id`, `--student-id` | + +### circle (班级圈) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `circle posts` | 查询学生班级圈动态 | `--class-id`, `--student-id`, `--target-role` | + +> `--target-role`: guardian(家长视角) / student(学生视角) +> 返回动态的文字内容、图片URL列表、发布者姓名、发布时间、评论数、点赞数等。 + +### card (打卡管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `card update` | 修改打卡任务的标题或内容(仅老师且为创建者) | `--card-id`, `--identifier`, (`--title` 或 `--content` 至少一个) | +| `card end` | 提前结束打卡任务(仅老师且为创建者) | `--card-id` | +| `card list` | 查询孩子/本人打卡列表(含进行中与已完结) | `--status` | +| `card user-statistic` | 查询班级已完成/未完成人员(仅老师/班主任) | `--card-id`, `--task-code`, `--class-id` | +| `card finish-info` | 查询打卡详情及完成进度 | `--card-id`, `--card-biz-id` | + +> `--status`: FINISH(已完结) / UNFINISH(进行中) +> `--identifier` 建议格式 `orgId-staffId-UUID`,用于幂等去重 +> `card finish-info --target-role`: teacher / headmaster / guardian / student,未传时按 uid 真实身份自动推断 +> `card finish-info` 当 `targetRole=guardian` 时,可传 `--student-id` 指定查看某个孩子的进度 + +## 意图判断 + +用户说"消息/消息摘要" → message summary-list +用户说"作业/任务/打卡/家校任务" → task 子命令 +用户说"成绩/成绩单" → report 子命令 +用户说"通知/确认通知" → notice confirm +用户说"班级圈/成长记录/学生动态" → circle posts +用户说"打卡/打卡任务/打卡完成情况/卡片完成情况" → card 子命令 + +关键区分: task(家校任务/作业) vs report(成绩单) +关键区分: circle(班级圈动态/成长记录) vs message(AI消息总结) + +## 核心工作流 + +### 老师场景 +1. 查看发布的任务 → `task publish-list --need-statistic -f json` +2. 查看某班全部任务 → `task all-list --biz-id ` +3. 查看成绩单 → `report by-teacher --status 1` +4. 查看班级成绩明细 → `report by-class --report-id --class-id ` +5. 修改打卡标题/内容 → `card update --card-id --identifier --title "新标题"` +6. 提前结束打卡 → `card end --card-id ` +7. 查看某班打卡完成情况 → `card user-statistic --card-id --task-code --class-id --finish` +8. 查看某班未打卡人员 → `card user-statistic --card-id --task-code --class-id ` +9. 查看某打卡完成进度 → `card finish-info --card-id --card-biz-id ` + +### 家长场景 +1. 查看孩子待办 → `task student-list --students '[{"userId":"","bizId":""}]'` +2. 确认通知 → `notice confirm --notice-id --student-id ` +3. 查看孩子班级圈动态 → `circle posts --class-id --student-id --target-role guardian` +4. 查看孩子进行中打卡 → `card list --status UNFINISH` +5. 查看孩子某打卡进度 → `card finish-info --card-id --card-biz-id ` + +### 学生场景 +1. 查看自己的班级圈动态 → `circle posts --class-id --student-id --target-role student` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `edu-contact school class-list` | `deptId` | task all-list 的 --biz-id | +| `edu-contact class students` | `userId` | task student-list 的 students.userId | +| `edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid | +| `report by-teacher` | `schoolReportId` | report get/by-class/by-student-detail 的 --report-id | +| `edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id | +| `task publish-list` | `cardId` | card update/end/finish-info` 的 --card-id | +| `task publish-list` | `taskCode` | card user-statistic 的 --task-code | diff --git a/skills/mono/references/products/edu-contact.md b/skills/mono/references/products/edu-contact.md new file mode 100644 index 00000000..91291454 --- /dev/null +++ b/skills/mono/references/products/edu-contact.md @@ -0,0 +1,74 @@ +# 家校通讯录 (edu-contact) 命令参考 + +## 命令总览 + +### school (学校/组织管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `school roles` | 查询用户在组织内的身份 | 无 | +| `school structure` | 查询学校组织架构 | 无 | +| `school periods` | 查询学校学段信息 | 无 | +| `school type` | 查询学校组织类型 | 无 | +| `school stats` | 查询学校统计数据 | `--statistics-type`(可选) | +| `school class-list` | 查询学校所有班级列表 | 无 | + +### class (班级管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `class detail` | 查询班级详情 | `--dept-id` | +| `class students` | 查询班级学生信息 | `--dept-id` | +| `class teachers` | 查询班级老师列表 | `--dept-id` | +| `class same-name` | 查询班级内同名学生 | `--dept-id` | +| `class user-role` | 查询用户在班级内的角色 | `--dept-id` | +| `class search-by-name` | 根据姓名查询班级 | `--query-type`, `--name` | +| `class headmaster` | 根据班级名查询班主任 | `--class-name` | +| `class search-by-teacher` | 根据老师姓名查询班级 | `--name` | +| `class update-student` | 更新学生信息 | `--class-id`, `--student-user-id` | +| `class add-student` | 添加学生到班级 | `--dept-id`, `--student-name` | +| `class modify-student-info` | 修改学生信息 | `--dept-id`, `--target-user-id` | +| `class delete-teacher` | 删除班级教师 ⚠️ | `--class-id`, `--teacher-user-id` | +| `class update-info` | 更新班级信息 | `--class-id` | +| `class update-student-number` | 修改学生学号 | `--class-id`, `--student-user-id`, `--student-number` | +| `class add-unofficial-student` | 添加非行政班学生 | `--dept-id`, `--student-staff-ids` | +| `class delete-students` | 批量删除学生 ⚠️ | `--dept-id`, `--student-user-ids` | +| `class update-student-mobile` | 修改学生手机号 | `--dept-id`, `--student-user-id`, `--mobile` | +| `class move-student` | 学生移班 | `--student-user-ids`, `--origin-class-id`, `--target-class-id` | +| `class add-teachers` | 批量添加班级教师 | `--dept-id`, `--teacher-user-ids` | + +### family (家庭关系查询) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `family children` | 查询家长的孩子信息 | 无 | +| `family parents` | 查询学生的家长信息 | 无 | + +### teacher (教师管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `teacher classes` | 查询老师管理的班级列表 | 无 | +| `teacher update-course` | 更新教师任教科目 | `--teacher-class-infos`(JSON数组) | + +## 意图判断 + +用户说"学校/组织/学段/组织架构" → school 子命令 +用户说"班级/学生/教师/班主任" → class 子命令 +用户说"家长/孩子/家庭关系" → family 子命令 +用户说"任教/科目" → teacher update-course + +## 核心工作流 + +1. 查询角色 → `school roles` +2. 查看班级列表 → `school class-list`(提取 deptId) +3. 查看班级详情 → `class detail --dept-id ` +4. 查看学生列表 → `class students --dept-id ` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `school class-list` | `deptId` | class 子命令的 --dept-id | +| `class students` | `userId` | update-student/delete-students 的 --student-user-id | +| `class teachers` | `userId` | delete-teacher 的 --teacher-user-id | diff --git a/skills/mono/references/products/edu-familygroup.md b/skills/mono/references/products/edu-familygroup.md new file mode 100644 index 00000000..82ec8631 --- /dev/null +++ b/skills/mono/references/products/edu-familygroup.md @@ -0,0 +1,68 @@ +# 家庭群 (edu-familygroup) 命令参考 + +## 命令总览 + +### group (家庭群查询) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `group check-exists` | 检查家庭群是否存在 | `--uid`, `--group-name` | +| `group list-children` | 查询家长绑定的孩子列表 | `--uid` | + +### manage (家庭群管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `manage create` | 创建家庭群 | `--uid`, `--children` | +| `manage invite-parent` | 短信邀请家长加入家庭群 | `--org-id`, `--uid`, `--mobile` | +| `manage add-child` | 为家庭群添加孩子 | `--org-id`, `--uid`, `--name`, (`--mobile` 或 `--students`) | +| `manage toggle-app` | 开启或关闭学生应用权限 | `--org-id`, `--uid`, `--child-staff-id`, `--app-type`, `--open` | + +## 意图判断 + +用户说"家庭群存在/有没有家庭群" → group check-exists +用户说"孩子列表/我的孩子" → group list-children +用户说"创建家庭/建群" → manage create +用户说"邀请家长/拉家长入群" → manage invite-parent +用户说"添加孩子/加娃" → manage add-child +用户说"应用权限/小天地/学习视频" → manage toggle-app + +## 核心工作流 + +1. 检查家庭群是否存在 → `group check-exists --uid --group-name ` +2. 如不存在,创建家庭群 → `manage create --uid --children ''` +3. 查看已绑定孩子 → `group list-children --uid ` +4. 邀请其他家长 → `manage invite-parent --org-id --uid --mobile ` +5. 添加孩子 → `manage add-child --org-id --uid --name --mobile ` +6. 管理应用权限 → `manage toggle-app --org-id --uid --child-staff-id --app-type XIAOTIANDI --open true` + +## 参数说明 + +### manage create --children 格式 + +```json +[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}] +``` + +每个孩子必填 name + students 数组(含 corpId、staffId),可选 birthday/gender/nick/avatar/period/grade/mobile。 + +### manage add-child --students 格式 + +```json +[{"schoolOrgId":111,"studentStaffId":"stu001"}] +``` + +每项必填 schoolOrgId(整数)+ studentStaffId(字符串)。 + +### manage toggle-app --app-type 可选值 + +- `XIAOTIANDI`:小天地(学生圈) +- `LEARNING_VIDEO`:学习视频 + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `manage create` | `orgId`, `cid` | invite-parent / add-child 的 --org-id | +| `group list-children` | 孩子 staffId | toggle-app 的 --child-staff-id | +| `edu-contact family list` | 家长 uid | 所有 edu-familygroup 命令的 --uid | diff --git a/skills/mono/references/products/edu-group.md b/skills/mono/references/products/edu-group.md new file mode 100644 index 00000000..794d24ca --- /dev/null +++ b/skills/mono/references/products/edu-group.md @@ -0,0 +1,55 @@ +# 家校群 (edu-group) 命令参考 + +## 命令总览 + +### student-group (师生群管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `student-group info` | 查询班级师生群信息 | `--dept-id` | +| `student-group exists` | 检查是否已创建师生群 | `--dept-id` | +| `student-group members` | 查询师生群成员列表 | `--dept-id` | +| `student-group is-in` | 判断用户是否在师生群中 | `--dept-id` | +| `student-group conversation` | 查询班级群会话详情 | `--dept-id` | +| `student-group create` | 创建班级师生群 | `--dept-id` | +| `student-group disband` | 解散班级师生群 ⚠️ | `--dept-id` | + +### class-group (班级群会话管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `class-group conversation-id` | 获取班级群会话ID | `--dept-id` | +| `class-group conversation` | 获取班级群完整会话信息 | `--dept-id` | +| `class-group exists` | 检查班级群是否存在 | `--dept-id` | +| `class-group list-by-cids` | 根据会话ID列表批量查询 | `--conversation-ids` | + +### batch (批量操作) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `batch check-student-group` | 批量检查是否已创建师生群 | `--class-ids` | +| `batch get-class-groups` | 批量获取班级群信息 | `--class-ids` | +| `batch create-student-groups` | 批量创建师生群 | 无 | + +## 意图判断 + +用户说"师生群/学生群" → student-group 子命令 +用户说"班级群/群会话" → class-group 子命令 +用户说"批量/一键操作" → batch 子命令 + +关键区分: student-group(师生群) vs class-group(班级群会话管理) + +## 核心工作流 + +1. 检查群是否存在 → `student-group exists --dept-id ` +2. 如不存在,创建 → `student-group create --dept-id ` +3. 查看成员 → `student-group members --dept-id ` +4. 获取会话ID → `class-group conversation-id --dept-id ` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `edu-contact school class-list` | `deptId` | 所有 edu-group 命令的 --dept-id | +| `class-group conversation-id` | `conversationId` | edu-app 消息命令的 --cid | +| `batch check-student-group` | 未创建群的班级 | batch create-student-groups | diff --git a/skills/multi/dingtalk-misc/SKILL.md b/skills/multi/dingtalk-misc/SKILL.md index d4eed650..a1f697d5 100644 --- a/skills/multi/dingtalk-misc/SKILL.md +++ b/skills/multi/dingtalk-misc/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-misc -description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 +description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile/家校应用/家校通讯录/家校群/高校通讯录。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile/家校任务/班级消息摘要/学校组织架构/班级列表/师生群/班级群/高校院系部门/师生员工管理 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 metadata: cli_version: ">=0.2.14" category: product @@ -37,6 +37,11 @@ metadata: | 原生 Markdown / `.md` 原文 / 覆盖 Markdown / 局部替换 Markdown | 原生 `.md` 文件读取、创建、全量覆盖与局部替换 | `dws markdown` | [markdown.md](references/markdown.md) | | PAT 授权 / 行为权限 / scope 授权 / 一次性授权 / 会话授权 / 永久授权 / 授权浏览器策略 | PAT 行为授权与本地浏览器策略 | `dws pat` | [pat.md](references/pat.md) | | 切换组织 / 换组织 / 跨组织 / 多组织 / profile / 看登录了哪些组织 | 多组织 / profile 管理与跨组织取数 | `dws profile` / `dws auth` / `--profile` | [profile.md](references/profile.md) | +| 家校应用 / 班级消息摘要 / 家校任务(教育场景) | 家校应用(教育版) | `dws edu-app` | [edu-app.md](references/edu-app.md) | +| 家校通讯录 / 学校组织架构 / 班级列表(教育场景) | 家校通讯录(教育版) | `dws edu-contact` | [edu-contact.md](references/edu-contact.md) | +| 家校群 / 师生群 / 班级群(教育场景) | 家校群(师生群)查询、创建、解散 | `dws edu-group` | [edu-group.md](references/edu-group.md) | +| 家庭群 / 家长邀请 / 孩子管理 / 学生应用权限(教育场景) | 家庭群查询/创建、孩子管理、家长邀请 | `dws edu-familygroup` | [edu-familygroup.md](references/edu-familygroup.md) | +| 高校通讯录 / 高校组织架构 / 院系部门 / 师生员工管理(高校场景) | 高校通讯录:部门与师生员工管理、搜索、统计、升级 | `dws college-contact` | [college-contact.md](references/college-contact.md) | | 宜搭 / AI应用脚本 / 财务辅助脚本(未产品化) | **无**稳定命令面;仅仓库内辅助脚本 | (非默认路由) | [unsupported-scripts.md](references/unsupported-scripts.md) | ## 说明 diff --git a/skills/multi/dingtalk-misc/references/college-contact.md b/skills/multi/dingtalk-misc/references/college-contact.md new file mode 100644 index 00000000..45622934 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/college-contact.md @@ -0,0 +1,135 @@ +# 高校通讯录 (college-contact) 命令参考 + +## 命令总览 + +### dept (部门管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `dept get-standard-structure` | 查询高校标准架构信息(组织 ID/行政架构部门 ID 映射) | 无 | +| `dept get-detail` | 查询部门详情 | `--dept-id` | +| `dept get-chain` | 查询部门链(根节点到当前部门) | `--dept-id` | +| `dept search` | 按关键词搜索通讯录(人员/部门/角色) | `--dept-id`, `--keyword` | +| `dept create` | 创建部门 | `--super-id`, `--stru-dept-id`, `--name`, `--dept-type`, `--create-dept-group` | +| `dept update` | 更新部门 | `--dept-id`, `--dept-type` | +| `dept delete` | 删除部门 ⚠️ | `--dept-id` | +| `dept batch-update-type` | 批量修改部门类型 | `--dept-ids`(逗号分隔), `--target-dept-type` | +| `dept overview` | 查询高校概览统计 | 无 | + +### employee (员工管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `employee get-detail` | 查询员工详情 | `--staff-id` | +| `employee add` | 添加员工(返回成功/失败数量及邮箱初始密码) | `--emp-type`, `--main-dept-id`, `--exclusive-account` | +| `employee remove` | 移除员工 ⚠️ | `--staff-ids`(逗号分隔) | +| `employee change-type` | 变更员工类型 | `--staff-id`, `--emp-type` | +| `employee change-dept` | 变更员工部门 | `--staff-id`, `--target-dept-id` | +| `employee send-active-sms` | 发送激活短信 | `--dept-id` | +| `employee list-employees` | 查询部门员工列表 | `--dept-id` | +| `employee list-unaccepted` | 查询未接受邀请的员工列表 | `--dept-id` | +| `employee list-unactive` | 查询未激活的员工列表 | `--dept-id` | +| `employee upgrade-status` | 查询高校通讯录升级状态 | 无 | +| `employee start-upgrade` | 启动高校通讯录升级 | 无 | + +### alumni (校友管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `alumni get-dept-tree` | 查询校友部门树 | `--alumni-dept-id` | +| `alumni get-info` | 查询校友部门详情 | `--alumni-dept-id` | +| `alumni list` | 查询校友列表 | `--alumni-dept-id`, `--order-field`, `--ordering` | +| `alumni query` | 查询单个校友详情 | `--staff-id` | +| `alumni search` | 搜索校友 | `--keyword` | +| `alumni list-unaccepted` | 查询未接受邀请的校友列表 | `--alumni-dept-id` | +| `alumni get-group` | 查询校友群信息 | `--alumni-dept-id` | +| `alumni create-dept` | 创建校友子部门 | `--alumni-dept-id`, `--dept-name` | +| `alumni update-dept` | 更新校友部门名称 | `--alumni-dept-id`, `--dept-name` | +| `alumni delete-dept` | 删除校友部门 ⚠️ | `--alumni-dept-id` | +| `alumni update-managers` | 设置校友部门负责人 | `--alumni-dept-id`, `--admin-user-ids`(逗号分隔) | +| `alumni add-alumnus` | 添加校友 | `--name`, `--mobile`, `--dept-ids`(逗号分隔) | +| `alumni update-alumnus` | 更新校友信息 | `--staff-id`, `--name`, `--dept-ids`(逗号分隔) | +| `alumni remove-alumnus` | 删除校友 ⚠️ | `--staff-id`, `--alumni-dept-id` | +| `alumni cancel-invite` | 取消校友邀请 ⚠️ | `--alumni-dept-id`, `--staff-ids`(逗号分隔) | +| `alumni create-group` | 创建校友群 | `--alumni-dept-id` | +| `alumni disband-group` | 解散校友群 ⚠️ | `--alumni-dept-id` | +| `alumni get-alumni-org-from-graduate` | 查询毕业生校友组织 | 无入参 | +| `alumni create-alumni-org` | 创建校友会组织 | `--org-name` | +| `alumni add-alumni-org-main-admins` | 添加校友会组织管理员 | `--admin-user-ids`(逗号分隔) | + +### graduate (毕业年级管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `graduate query-graduate-years` | 查询毕业年级列表 | 无入参 | +| `graduate query-graduate-depts` | 查询待毕业部门列表 | `--dept-id`, `--graduate-year`(可选) | +| `graduate query-graduate-sub-depts` | 查询毕业子部门列表 | `--dept-id` | +| `graduate query-page-graduate-users` | 分页查询待毕业学生列表 | `--dept-id`, `--graduate-year`/`--offset`/`--size`(可选) | +| `graduate get-task-result` | 查询异步任务执行结果 | `--request-no`, `--type`(可选) | +| `graduate get-alumni-org` | 查询校友组织信息 | 无入参 | +| `graduate query-restore-sub-depts` | 查询可恢复子部门列表 | `--dept-id` | +| `graduate query-dept-deleted-emps` | 查询部门可恢复员工列表 | `--dept-id`, `--offset`/`--size`(可选) | +| `graduate search-graduate` | 搜索毕业部门与员工 | `--keyword`, `--offset`/`--size`(可选) | +| `graduate commit-graduate` | 提交毕业 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--graduate-year`, `--request-no`(可选) | +| `graduate all-graduate` | 全部毕业 ⚠️ | `--graduate-year`, `--request-no`(可选) | +| `graduate batch-graduate` | 批量毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) | +| `graduate delete-and-graduate` | 删除并毕业 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) | +| `graduate batch-delete-pending` | 批量删除待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔) | +| `graduate batch-update-pending` | 批量更新待毕业学生 ⚠️ | `--dept-id`, `--staff-ids`(逗号分隔), `--graduate-year` | +| `graduate commit-restore` | 提交恢复 ⚠️ | `--graduate-dept-ids`(逗号分隔), `--request-no`(可选) | + +### group (规则管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `group query-group-rule` | 查询规则 | `--name`(可选), `--offset`(可选), `--size`(可选) | +| `group get-group-rule-schedule` | 查询规则调度 | 无参数 | +| `group query-preview-data` | 查询规则预览数据 | `--offset`(可选), `--size`(可选) | +| `group create-group-rule` | 创建规则 | `--name`, `--tag-code`, `--dept-type`, `--auto-admin`(可选,true/false) | +| `group delete-group-rule` | 删除规则 ⚠️ | `--rule-id` | +| `group enable-group-rule` | 启用规则 | `--rule-id` | +| `group disable-group-rule` | 停用规则 | `--rule-id` | +| `group set-group-rule-schedule` | 设置规则调度 | `--cron`(可选) | +| `group execute-group-rule` | 立即执行规则 ⚠️ | 无参数 | + +## 常用参数说明 + +- `--emp-type`:员工类型,取值 `college_student`(学生)/ `college_teacher`(教职工) +- `--dept-type`:部门类型(如 `contact_grade_dept` 年级 / `contact_class_dept` 班级 / `contact_major_dept` 专业) +- `--staff-id` 单个员工 staffId;`--staff-ids` 为逗号分隔的批量列表 +- 列表类命令支持 `--offset` / `--size` 分页与 `--order-field` / `--ordering`(asc/desc) 排序 +- `--exclusive-account`、`--create-dept-group`、`--send-active-sms` 为布尔参数(true/false) + +## 意图判断 + +用户说"高校架构/组织架构/学院/系/部门" → dept 子命令 +用户说"搜人/找某某老师/找某某同学" → `dept search` +用户说"师生/教职工/学生/员工/辅导员" → employee 子命令 +用户说"激活/邀请/未激活账号" → `employee list-unactive` / `list-unaccepted` / `send-active-sms` +用户说"通讯录升级" → `employee upgrade-status` / `start-upgrade` +用户说"校友/校友会/校友部门/添加校友" → alumni 子命令 +用户说"毕业年级/毕业年份/待毕业学生/毕业操作" → graduate 子命令 +用户说"群规则/建群规则/自动建群" → group 子命令 + +## 核心工作流 + +1. 查标准架构 → `dept get-standard-structure`(提取 deptId) +2. 查看部门详情 → `dept get-detail --dept-id ` +3. 查看部门员工 → `employee list-employees --dept-id ` +4. 查看员工详情 → `employee get-detail --staff-id ` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `dept get-standard-structure` | `deptId` | dept/employee 子命令的 --dept-id | +| `employee list-employees` | `staffId` | get-detail/change-type/change-dept 的 --staff-id、remove 的 --staff-ids | +| `dept create` | `deptId` | update/delete 的 --dept-id | +| `alumni get-dept-tree` | `alumniDeptId` | alumni 子命令的 --alumni-dept-id | +| `alumni list` | `staffId` | update-alumnus/remove-alumnus 的 --staff-id | +| `graduate query-graduate-depts` | `deptId` | graduate 子命令的 --dept-id | +| `group query-group-rule` | `ruleId` | delete/enable/disable-group-rule 的 --rule-id | + +## 危险操作 + +- `dept delete`、`employee remove`、`alumni delete-dept`、`alumni remove-alumnus`、`alumni cancel-invite`、`alumni disband-group`、`graduate commit-graduate`、`graduate all-graduate`、`graduate batch-graduate`、`graduate delete-and-graduate`、`graduate batch-delete-pending`、`graduate batch-update-pending`、`graduate commit-restore`、`group delete-group-rule`、`group execute-group-rule` 不可逆:非 --dry-run 预览时必须显式传入 --yes 才会真实执行,未传 --yes 会直接拒绝。执行前必须向用户展示操作摘要并获得明确同意,确认后再追加 --yes。 diff --git a/skills/multi/dingtalk-misc/references/edu-app.md b/skills/multi/dingtalk-misc/references/edu-app.md new file mode 100644 index 00000000..256bffd7 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/edu-app.md @@ -0,0 +1,115 @@ +# 家校应用 (edu-app) 命令参考 + +## 命令总览 + +### message (消息管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `message summary-list` | 查询消息摘要列表 | `--class-id`, `--cid`, `--target-role`, `--status` | + +> `--target-role`: guardian(家长) / student(学生) +> `--status`: 0(未处理) / 1(已处理) + +### task (任务管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `task publish-list` | 查询发布的家校任务列表(仅老师) | 无(均可选) | +| `task all-list` | 查询全部家校任务列表(仅老师) | `--biz-id`(班级ID) | +| `task student-list` | 查询学生待办任务列表 | `--students`(JSON数组) | + +> `--task-sources` 可选值(逗号分隔): EDU_HOMEWORK, EDU_CARD, EDU_NOTICE, EDU_SR, EDU_DIPLOMA + +### report (成绩单管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `report get` | 获取成绩单列表 | `--ids`(逗号分隔整数) | +| `report by-teacher` | 查询老师创建的成绩单 | 无(均可选) | +| `report by-class` | 查询班级学生成绩明细 | `--report-id`, `--class-id` | +| `report by-student-list` | 查询学生收到的成绩单 | `--class-id`, `--student-id` | +| `report by-student-detail` | 查询学生成绩明细 | `--report-id`, `--student-id`, `--class-id` | + +### notice (通知管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `notice confirm` | 确认收到通知 | `--notice-id`, `--student-id` | + +### circle (班级圈) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `circle posts` | 查询学生班级圈动态 | `--class-id`, `--student-id`, `--target-role` | + +> `--target-role`: guardian(家长视角) / student(学生视角) +> 返回动态的文字内容、图片URL列表、发布者姓名、发布时间、评论数、点赞数等。 + +### card (打卡管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `card update` | 修改打卡标题或内容(仅创建者) | `--card-id`, `--identifier`, `--title`/`--content` | +| `card end` | 提前结束打卡任务(仅创建者) | `--card-id` | +| `card list` | 查询打卡列表(学生/家长) | `--status`(FINISH/UNFINISH) | +| `card user-statistic` | 查询班级打卡完成/未完成人员(老师/班主任) | `--card-id`, `--task-code`, `--class-id` | +| `card finish-info` | 查询打卡详情及完成进度 | `--card-id`, `--card-biz-id` | + +> `card list --status`: FINISH(已完结) / UNFINISH(进行中) +> `card finish-info --target-role`: teacher / headmaster / guardian / student,未传时按 uid 真实身份自动推断 +> `card finish-info --student-id`: 当 targetRole 为 guardian 时,用于指定查看某个孩子的进度 + +## 意图判断 + +用户说"消息/消息摘要" → message summary-list +用户说"作业/任务/打卡/家校任务" → task 子命令 +用户说"成绩/成绩单" → report 子命令 +用户说"通知/确认通知" → notice confirm +用户说"班级圈/成长记录/学生动态" → circle posts +用户说"打卡/打卡列表/打卡进度/打卡统计/卡片完成情况" → card 子命令 + +关键区分: task(家校任务/作业) vs report(成绩单) +关键区分: circle(班级圈动态/成长记录) vs message(AI消息总结) + +## 核心工作流 + +### 老师场景 +1. 查看发布的任务 → `task publish-list --need-statistic -f json` +2. 查看某班全部任务 → `task all-list --biz-id ` +3. 查看成绩单 → `report by-teacher --status 1` +4. 查看班级成绩明细 → `report by-class --report-id --class-id ` + +### 家长场景 +1. 查看孩子待办 → `task student-list --students '[{"userId":"","bizId":""}]'` +2. 确认通知 → `notice confirm --notice-id --student-id ` +3. 查看孩子班级圈动态 → `circle posts --class-id --student-id --target-role guardian` + +### 学生场景 +1. 查看自己的班级圈动态 → `circle posts --class-id --student-id --target-role student` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `edu-contact school class-list` | `deptId` | task all-list 的 --biz-id | +| `edu-contact class students` | `userId` | task student-list 的 students.userId | +| `edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid | +| `report by-teacher` | `schoolReportId` | report get/by-class/by-student-detail 的 --report-id | +| `edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id | + +--- + +## SKILL 摘要(原 dingtalk-edu-app/SKILL.md 正文) + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "查班级消息摘要" | `dws edu-app message summary-list --class-id --cid --target-role guardian\|student --status 0\|1` | +| "查家校任务" | 见 [edu-app.md](./edu-app.md) `task` 章节 | + +## 跨产品协作 + +- 师生群本身 → 见本包 references/edu-group.md +- 家校通讯录 → 见本包 references/edu-contact.md diff --git a/skills/multi/dingtalk-misc/references/edu-contact.md b/skills/multi/dingtalk-misc/references/edu-contact.md new file mode 100644 index 00000000..5d9b4c52 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/edu-contact.md @@ -0,0 +1,92 @@ +# 家校通讯录 (edu-contact) 命令参考 + +## 命令总览 + +### school (学校/组织管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `school roles` | 查询用户在组织内的身份 | 无 | +| `school structure` | 查询学校组织架构 | 无 | +| `school periods` | 查询学校学段信息 | 无 | +| `school type` | 查询学校组织类型 | 无 | +| `school stats` | 查询学校统计数据 | `--statistics-type`(可选) | +| `school class-list` | 查询学校所有班级列表 | 无 | + +### class (班级管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `class detail` | 查询班级详情 | `--dept-id` | +| `class students` | 查询班级学生信息 | `--dept-id` | +| `class teachers` | 查询班级老师列表 | `--dept-id` | +| `class same-name` | 查询班级内同名学生 | `--dept-id` | +| `class user-role` | 查询用户在班级内的角色 | `--dept-id` | +| `class search-by-name` | 根据姓名查询班级 | `--query-type`, `--name` | +| `class headmaster` | 根据班级名查询班主任 | `--class-name` | +| `class search-by-teacher` | 根据老师姓名查询班级 | `--name` | +| `class update-student` | 更新学生信息 | `--class-id`, `--student-user-id` | +| `class add-student` | 添加学生到班级 | `--dept-id`, `--student-name` | +| `class modify-student-info` | 修改学生信息 | `--dept-id`, `--target-user-id` | +| `class delete-teacher` | 删除班级教师 ⚠️ | `--class-id`, `--teacher-user-id` | +| `class update-info` | 更新班级信息 | `--class-id` | +| `class update-student-number` | 修改学生学号 | `--class-id`, `--student-user-id`, `--student-number` | +| `class add-unofficial-student` | 添加非行政班学生 | `--dept-id`, `--student-staff-ids` | +| `class delete-students` | 批量删除学生 ⚠️ | `--dept-id`, `--student-user-ids` | +| `class update-student-mobile` | 修改学生手机号 | `--dept-id`, `--student-user-id`, `--mobile` | +| `class move-student` | 学生移班 | `--student-user-ids`, `--origin-class-id`, `--target-class-id` | +| `class add-teachers` | 批量添加班级教师 | `--dept-id`, `--teacher-user-ids` | + +### family (家庭关系查询) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `family children` | 查询家长的孩子信息 | 无 | +| `family parents` | 查询学生的家长信息 | 无 | + +### teacher (教师管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `teacher classes` | 查询老师管理的班级列表 | 无 | +| `teacher update-course` | 更新教师任教科目 | `--teacher-class-infos`(JSON数组) | + +## 意图判断 + +用户说"学校/组织/学段/组织架构" → school 子命令 +用户说"班级/学生/教师/班主任" → class 子命令 +用户说"家长/孩子/家庭关系" → family 子命令 +用户说"任教/科目" → teacher update-course + +## 核心工作流 + +1. 查询角色 → `school roles` +2. 查看班级列表 → `school class-list`(提取 deptId) +3. 查看班级详情 → `class detail --dept-id ` +4. 查看学生列表 → `class students --dept-id ` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `school class-list` | `deptId` | class 子命令的 --dept-id | +| `class students` | `userId` | update-student/delete-students 的 --student-user-id | +| `class teachers` | `userId` | delete-teacher 的 --teacher-user-id | + +--- + +## SKILL 摘要(原 dingtalk-edu-contact/SKILL.md 正文) + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "我在学校的身份" | `dws edu-contact school roles` | +| "学校组织架构" | `dws edu-contact school structure` | +| "学校学段 / 类型" | `dws edu-contact school periods` / `school type` | +| "学校所有班级" | `dws edu-contact school class-list` | +| "学校统计" | `dws edu-contact school stats [--statistics-type ]` | + +## 跨产品协作 + +- 企业通讯录场景 → 切到 `dingtalk-contact` diff --git a/skills/multi/dingtalk-misc/references/edu-familygroup.md b/skills/multi/dingtalk-misc/references/edu-familygroup.md new file mode 100644 index 00000000..82ec8631 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/edu-familygroup.md @@ -0,0 +1,68 @@ +# 家庭群 (edu-familygroup) 命令参考 + +## 命令总览 + +### group (家庭群查询) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `group check-exists` | 检查家庭群是否存在 | `--uid`, `--group-name` | +| `group list-children` | 查询家长绑定的孩子列表 | `--uid` | + +### manage (家庭群管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `manage create` | 创建家庭群 | `--uid`, `--children` | +| `manage invite-parent` | 短信邀请家长加入家庭群 | `--org-id`, `--uid`, `--mobile` | +| `manage add-child` | 为家庭群添加孩子 | `--org-id`, `--uid`, `--name`, (`--mobile` 或 `--students`) | +| `manage toggle-app` | 开启或关闭学生应用权限 | `--org-id`, `--uid`, `--child-staff-id`, `--app-type`, `--open` | + +## 意图判断 + +用户说"家庭群存在/有没有家庭群" → group check-exists +用户说"孩子列表/我的孩子" → group list-children +用户说"创建家庭/建群" → manage create +用户说"邀请家长/拉家长入群" → manage invite-parent +用户说"添加孩子/加娃" → manage add-child +用户说"应用权限/小天地/学习视频" → manage toggle-app + +## 核心工作流 + +1. 检查家庭群是否存在 → `group check-exists --uid --group-name ` +2. 如不存在,创建家庭群 → `manage create --uid --children ''` +3. 查看已绑定孩子 → `group list-children --uid ` +4. 邀请其他家长 → `manage invite-parent --org-id --uid --mobile ` +5. 添加孩子 → `manage add-child --org-id --uid --name --mobile ` +6. 管理应用权限 → `manage toggle-app --org-id --uid --child-staff-id --app-type XIAOTIANDI --open true` + +## 参数说明 + +### manage create --children 格式 + +```json +[{"name":"小明","students":[{"corpId":"dingxxx","staffId":"stu001"}]}] +``` + +每个孩子必填 name + students 数组(含 corpId、staffId),可选 birthday/gender/nick/avatar/period/grade/mobile。 + +### manage add-child --students 格式 + +```json +[{"schoolOrgId":111,"studentStaffId":"stu001"}] +``` + +每项必填 schoolOrgId(整数)+ studentStaffId(字符串)。 + +### manage toggle-app --app-type 可选值 + +- `XIAOTIANDI`:小天地(学生圈) +- `LEARNING_VIDEO`:学习视频 + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `manage create` | `orgId`, `cid` | invite-parent / add-child 的 --org-id | +| `group list-children` | 孩子 staffId | toggle-app 的 --child-staff-id | +| `edu-contact family list` | 家长 uid | 所有 edu-familygroup 命令的 --uid | diff --git a/skills/multi/dingtalk-misc/references/edu-group.md b/skills/multi/dingtalk-misc/references/edu-group.md new file mode 100644 index 00000000..056327a6 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/edu-group.md @@ -0,0 +1,77 @@ +# 家校群 (edu-group) 命令参考 + +## 命令总览 + +### student-group (师生群管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `student-group info` | 查询班级师生群信息 | `--dept-id` | +| `student-group exists` | 检查是否已创建师生群 | `--dept-id` | +| `student-group members` | 查询师生群成员列表 | `--dept-id` | +| `student-group is-in` | 判断用户是否在师生群中 | `--dept-id` | +| `student-group conversation` | 查询班级群会话详情 | `--dept-id` | +| `student-group create` | 创建班级师生群 | `--dept-id` | +| `student-group disband` | 解散班级师生群 ⚠️ | `--dept-id` | + +### class-group (班级群会话管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `class-group conversation-id` | 获取班级群会话ID | `--dept-id` | +| `class-group conversation` | 获取班级群完整会话信息 | `--dept-id` | +| `class-group exists` | 检查班级群是否存在 | `--dept-id` | +| `class-group list-by-cids` | 根据会话ID列表批量查询 | `--conversation-ids` | + +### batch (批量操作) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `batch check-student-group` | 批量检查是否已创建师生群 | `--class-ids` | +| `batch get-class-groups` | 批量获取班级群信息 | `--class-ids` | +| `batch create-student-groups` | 批量创建师生群 | 无 | + +## 意图判断 + +用户说"师生群/学生群" → student-group 子命令 +用户说"班级群/群会话" → class-group 子命令 +用户说"批量/一键操作" → batch 子命令 + +关键区分: student-group(师生群) vs class-group(班级群会话管理) + +## 核心工作流 + +1. 检查群是否存在 → `student-group exists --dept-id ` +2. 如不存在,创建 → `student-group create --dept-id ` +3. 查看成员 → `student-group members --dept-id ` +4. 获取会话ID → `class-group conversation-id --dept-id ` + +## 上下文传递表 + +| 操作 | 从返回中提取 | 用于 | +|------|-------------|------| +| `edu-contact school class-list` | `deptId` | 所有 edu-group 命令的 --dept-id | +| `class-group conversation-id` | `conversationId` | edu-app 消息命令的 --cid | +| `batch check-student-group` | 未创建群的班级 | batch create-student-groups | + +--- + +## SKILL 摘要(原 dingtalk-edu-group/SKILL.md 正文) + +## 意图表 + +| 用户说 | 命令 | +|--------|------| +| "查师生群信息 / 是否已建" | `dws edu-group student-group info --dept-id ` / `exists --dept-id ` | +| "师生群成员" | `dws edu-group student-group members --dept-id ` | +| "建班级师生群" | `dws edu-group student-group create --dept-id ` | +| "解散班级师生群 ⚠️" | `dws edu-group student-group disband --dept-id `(需用户确认 `--yes`) | + +## 危险操作 + +`student-group disband` 不可逆,必须先向用户确认再加 `--yes`。 + +## 跨产品协作 + +- 班级列表 → 见本包 references/edu-contact.md(school class-list) +- 企业群 → 切到 `dingtalk-chat` From 1b22b79fa93e42d96cbb0252a6f5020a8a89a2db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=8D=B7?= Date: Mon, 24 Aug 2026 10:47:17 +0800 Subject: [PATCH 03/33] fix: replace --help examples with real execution examples in college-contact The schema catalog validator requires Examples to demonstrate actual execution, not just --help. Replace all 65 occurrences with realistic parameter examples derived from each command's required flags. --- internal/helpers/college_contact.go | 130 ++++++++++++++-------------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/internal/helpers/college_contact.go b/internal/helpers/college_contact.go index 65fb4ca2..06b0bb9e 100644 --- a/internal/helpers/college_contact.go +++ b/internal/helpers/college_contact.go @@ -112,7 +112,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询高校标准架构信息", UseWhen: []string{"需要查询高校标准架构信息时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept get-standard-structure --help"}, + Examples: []string{"dws college-contact dept get-standard-structure --format json"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, @@ -194,7 +194,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询部门详情", UseWhen: []string{"需要查询部门详情时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept get-detail --help"}, + Examples: []string{"dws college-contact dept get-detail --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -276,7 +276,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询部门链", UseWhen: []string{"需要查询部门链时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept get-chain --help"}, + Examples: []string{"dws college-contact dept get-chain --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -359,7 +359,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "搜索通讯录", UseWhen: []string{"需要搜索通讯录时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept search --help"}, + Examples: []string{"dws college-contact dept search --dept-id 12345 --keyword 计算机"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -516,7 +516,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "创建部门", UseWhen: []string{"需要创建部门时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept create --help"}, + Examples: []string{"dws college-contact dept create --super-id 12345 --stru-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "super-id", Property: "input.superId", Required: boolPtr(true)}, @@ -653,7 +653,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "更新部门", UseWhen: []string{"需要更新部门时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept update --help"}, + Examples: []string{"dws college-contact dept update --dept-id 12345 --dept-type standard"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -732,7 +732,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "删除部门", UseWhen: []string{"需要删除部门时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept delete --help"}, + Examples: []string{"dws college-contact dept delete --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -813,7 +813,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "批量修改部门类型", UseWhen: []string{"需要批量修改部门类型时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept batch-update-type --help"}, + Examples: []string{"dws college-contact dept batch-update-type --dept-ids 12345 --target-dept-type standard"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-ids", Property: "input.deptIds", Required: boolPtr(true)}, @@ -960,7 +960,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询部门员工列表", UseWhen: []string{"需要查询部门员工列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee list-employees --help"}, + Examples: []string{"dws college-contact employee list-employees --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -1135,7 +1135,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询未接受邀请的员工列表", UseWhen: []string{"需要查询未接受邀请的员工列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee list-unaccepted --help"}, + Examples: []string{"dws college-contact employee list-unaccepted --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -1310,7 +1310,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询未激活的员工列表", UseWhen: []string{"需要查询未激活的员工列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee list-unactive --help"}, + Examples: []string{"dws college-contact employee list-unactive --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -1424,7 +1424,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询高校概览统计", UseWhen: []string{"需要查询高校概览统计时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept overview --help"}, + Examples: []string{"dws college-contact dept overview --format json"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, @@ -1514,7 +1514,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询升级状态", UseWhen: []string{"需要查询升级状态时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee upgrade-status --help"}, + Examples: []string{"dws college-contact employee upgrade-status --format json"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(false)}, @@ -1568,7 +1568,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "启动升级", UseWhen: []string{"需要启动升级时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee start-upgrade --help"}, + Examples: []string{"dws college-contact employee start-upgrade --format json"}, }, Parameters: []contract.ParamDecl{}, }, @@ -1713,7 +1713,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询员工详情", UseWhen: []string{"需要查询员工详情时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee get-detail --help"}, + Examples: []string{"dws college-contact employee get-detail --staff-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "staff-id", Property: "input.staffId", Required: boolPtr(true)}, @@ -1900,7 +1900,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "添加员工", UseWhen: []string{"需要添加员工时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee add --help"}, + Examples: []string{"dws college-contact employee add --emp-type standard --main-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "emp-type", Property: "input.empType", Required: boolPtr(true)}, @@ -2078,7 +2078,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "移除员工", UseWhen: []string{"需要移除员工时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee remove --help"}, + Examples: []string{"dws college-contact employee remove --staff-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "staff-ids", Property: "input.staffIds", Required: boolPtr(true)}, @@ -2258,7 +2258,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "变更员工类型", UseWhen: []string{"需要变更员工类型时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee change-type --help"}, + Examples: []string{"dws college-contact employee change-type --staff-id 12345 --emp-type standard"}, }, Parameters: []contract.ParamDecl{ {Name: "staff-id", Property: "input.staffId", Required: boolPtr(true)}, @@ -2442,7 +2442,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "变更员工部门", UseWhen: []string{"需要变更员工部门时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee change-dept --help"}, + Examples: []string{"dws college-contact employee change-dept --staff-id 12345 --target-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "staff-id", Property: "input.staffId", Required: boolPtr(true)}, @@ -2619,7 +2619,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "发送激活短信", UseWhen: []string{"需要发送激活短信时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee send-active-sms --help"}, + Examples: []string{"dws college-contact employee send-active-sms --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -2712,7 +2712,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询校友部门树", UseWhen: []string{"需要查询校友部门树时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni get-dept-tree --help"}, + Examples: []string{"dws college-contact alumni get-dept-tree --alumni-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -2769,7 +2769,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询校友部门详情", UseWhen: []string{"需要查询校友部门详情时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni get-info --help"}, + Examples: []string{"dws college-contact alumni get-info --alumni-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -2866,7 +2866,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询校友列表", UseWhen: []string{"需要查询校友列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni list --help"}, + Examples: []string{"dws college-contact alumni list --alumni-dept-id 12345 --order-field test"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -2927,7 +2927,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询校友详情", UseWhen: []string{"需要查询校友详情时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni query --help"}, + Examples: []string{"dws college-contact alumni query --staff-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "staff-id", Property: "input.staffId", Required: boolPtr(true)}, @@ -3002,7 +3002,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "搜索校友", UseWhen: []string{"需要搜索校友时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni search --help"}, + Examples: []string{"dws college-contact alumni search --keyword 计算机"}, }, Parameters: []contract.ParamDecl{ {Name: "keyword", Property: "input.keyword", Required: boolPtr(true)}, @@ -3085,7 +3085,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询未接受邀请的校友列表", UseWhen: []string{"需要查询未接受邀请的校友列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni list-unaccepted --help"}, + Examples: []string{"dws college-contact alumni list-unaccepted --alumni-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -3148,7 +3148,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询校友群", UseWhen: []string{"需要查询校友群时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni get-group --help"}, + Examples: []string{"dws college-contact alumni get-group --alumni-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -3213,7 +3213,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "创建校友子部门", UseWhen: []string{"需要创建校友子部门时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni create-dept --help"}, + Examples: []string{"dws college-contact alumni create-dept --alumni-dept-id 12345 --dept-name 测试"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -3280,7 +3280,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "更新校友部门", UseWhen: []string{"需要更新校友部门时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni update-dept --help"}, + Examples: []string{"dws college-contact alumni update-dept --alumni-dept-id 12345 --dept-name 测试"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -3348,7 +3348,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "删除校友部门", UseWhen: []string{"需要删除校友部门时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni delete-dept --help"}, + Examples: []string{"dws college-contact alumni delete-dept --alumni-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -3427,7 +3427,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "设置校友部门负责人", UseWhen: []string{"需要设置校友部门负责人时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni update-managers --help"}, + Examples: []string{"dws college-contact alumni update-managers --alumni-dept-id 12345 --admin-user-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -3534,7 +3534,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "添加校友", UseWhen: []string{"需要添加校友时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni add-alumnus --help"}, + Examples: []string{"dws college-contact alumni add-alumnus --dept-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "name", Property: "input.name", Required: boolPtr(false)}, @@ -3651,7 +3651,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "更新校友信息", UseWhen: []string{"需要更新校友信息时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni update-alumnus --help"}, + Examples: []string{"dws college-contact alumni update-alumnus --dept-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, @@ -3741,7 +3741,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "删除校友", UseWhen: []string{"需要删除校友时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni remove-alumnus --help"}, + Examples: []string{"dws college-contact alumni remove-alumnus --staff-id 12345 --alumni-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "staff-id", Property: "input.staffId", Required: boolPtr(true)}, @@ -3831,7 +3831,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "取消校友邀请", UseWhen: []string{"需要取消校友邀请时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni cancel-invite --help"}, + Examples: []string{"dws college-contact alumni cancel-invite --alumni-dept-id 12345 --staff-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -3895,7 +3895,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "创建校友群", UseWhen: []string{"需要创建校友群时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni create-group --help"}, + Examples: []string{"dws college-contact alumni create-group --alumni-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -3965,7 +3965,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "解散校友群", UseWhen: []string{"需要解散校友群时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni disband-group --help"}, + Examples: []string{"dws college-contact alumni disband-group --alumni-dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -4012,7 +4012,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询毕业生校友组织", UseWhen: []string{"需要查询毕业生校友组织时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni get-alumni-org-from-graduate --help"}, + Examples: []string{"dws college-contact alumni get-alumni-org-from-graduate --format json"}, }, Parameters: []contract.ParamDecl{}, }, @@ -4066,7 +4066,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "创建校友会组织", UseWhen: []string{"需要创建校友会组织时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni create-alumni-org --help"}, + Examples: []string{"dws college-contact alumni create-alumni-org --org-name 测试"}, }, Parameters: []contract.ParamDecl{ {Name: "org-name", Property: "input.orgName", Required: boolPtr(true)}, @@ -4134,7 +4134,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "添加校友会组织管理员", UseWhen: []string{"需要添加校友会组织管理员时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni add-alumni-org-main-admins --help"}, + Examples: []string{"dws college-contact alumni add-alumni-org-main-admins --admin-user-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "admin-user-ids", Property: "input.adminUserIds", Required: boolPtr(true)}, @@ -4187,7 +4187,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询毕业年级列表", UseWhen: []string{"需要查询毕业年级列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate query-graduate-years --help"}, + Examples: []string{"dws college-contact graduate query-graduate-years --format json"}, }, Parameters: []contract.ParamDecl{}, }, @@ -4255,7 +4255,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询待毕业部门列表", UseWhen: []string{"需要查询待毕业部门列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate query-graduate-depts --help"}, + Examples: []string{"dws college-contact graduate query-graduate-depts --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -4319,7 +4319,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询毕业子部门列表", UseWhen: []string{"需要查询毕业子部门列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate query-graduate-sub-depts --help"}, + Examples: []string{"dws college-contact graduate query-graduate-sub-depts --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -4402,7 +4402,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "分页查询待毕业学生列表", UseWhen: []string{"需要分页查询待毕业学生列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate query-page-graduate-users --help"}, + Examples: []string{"dws college-contact graduate query-page-graduate-users --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -4472,7 +4472,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询异步任务执行结果", UseWhen: []string{"需要查询异步任务执行结果时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate get-task-result --help"}, + Examples: []string{"dws college-contact graduate get-task-result --request-no test"}, }, Parameters: []contract.ParamDecl{ {Name: "request-no", Property: "input.requestNo", Required: boolPtr(true)}, @@ -4521,7 +4521,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询校友组织信息", UseWhen: []string{"需要查询校友组织信息时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate get-alumni-org --help"}, + Examples: []string{"dws college-contact graduate get-alumni-org --format json"}, }, Parameters: []contract.ParamDecl{}, }, @@ -4579,7 +4579,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询可恢复子部门列表", UseWhen: []string{"需要查询可恢复子部门列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate query-restore-sub-depts --help"}, + Examples: []string{"dws college-contact graduate query-restore-sub-depts --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -4660,7 +4660,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询部门可恢复员工列表", UseWhen: []string{"需要查询部门可恢复员工列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate query-dept-deleted-emps --help"}, + Examples: []string{"dws college-contact graduate query-dept-deleted-emps --dept-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -4741,7 +4741,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "搜索毕业部门与员工", UseWhen: []string{"需要搜索毕业部门与员工时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate search-graduate --help"}, + Examples: []string{"dws college-contact graduate search-graduate --keyword 计算机"}, }, Parameters: []contract.ParamDecl{ {Name: "keyword", Property: "input.keyword", Required: boolPtr(true)}, @@ -4847,7 +4847,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "提交毕业", UseWhen: []string{"需要提交毕业时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate commit-graduate --help"}, + Examples: []string{"dws college-contact graduate commit-graduate --graduate-dept-ids 12345 --graduate-year 2024"}, }, Parameters: []contract.ParamDecl{ {Name: "graduate-dept-ids", Property: "input.graduateDeptIds", Required: boolPtr(true)}, @@ -4928,7 +4928,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "全部毕业", UseWhen: []string{"需要全部毕业时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate all-graduate --help"}, + Examples: []string{"dws college-contact graduate all-graduate --graduate-year 2024"}, }, Parameters: []contract.ParamDecl{ {Name: "graduate-year", Property: "input.graduateYear", Required: boolPtr(true)}, @@ -5019,7 +5019,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "批量毕业", UseWhen: []string{"需要批量毕业时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate batch-graduate --help"}, + Examples: []string{"dws college-contact graduate batch-graduate --dept-id 12345 --staff-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -5110,7 +5110,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "删除并毕业", UseWhen: []string{"需要删除并毕业时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate delete-and-graduate --help"}, + Examples: []string{"dws college-contact graduate delete-and-graduate --dept-id 12345 --staff-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -5201,7 +5201,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "批量删除待毕业学生", UseWhen: []string{"需要批量删除待毕业学生时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate batch-delete-pending --help"}, + Examples: []string{"dws college-contact graduate batch-delete-pending --dept-id 12345 --staff-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -5304,7 +5304,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "批量更新待毕业学生", UseWhen: []string{"需要批量更新待毕业学生时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate batch-update-pending --help"}, + Examples: []string{"dws college-contact graduate batch-update-pending --dept-id 12345 --staff-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -5396,7 +5396,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "提交恢复", UseWhen: []string{"需要提交恢复时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate commit-restore --help"}, + Examples: []string{"dws college-contact graduate commit-restore --graduate-dept-ids 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "graduate-dept-ids", Property: "input.graduateDeptIds", Required: boolPtr(true)}, @@ -5477,7 +5477,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询规则", UseWhen: []string{"需要查询规则时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact group query-group-rule --help"}, + Examples: []string{"dws college-contact group query-group-rule --format json"}, }, Parameters: []contract.ParamDecl{ {Name: "name", Property: "input.name", Required: boolPtr(false)}, @@ -5528,7 +5528,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询规则调度", UseWhen: []string{"需要查询规则调度时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact group get-group-rule-schedule --help"}, + Examples: []string{"dws college-contact group get-group-rule-schedule --format json"}, }, Parameters: []contract.ParamDecl{}, }, @@ -5591,7 +5591,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询规则预览数据", UseWhen: []string{"需要查询规则预览数据时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact group query-preview-data --help"}, + Examples: []string{"dws college-contact group query-preview-data --format json"}, }, Parameters: []contract.ParamDecl{ {Name: "offset", Property: "input.offset", Required: boolPtr(false)}, @@ -5677,7 +5677,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "创建规则", UseWhen: []string{"需要创建规则时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact group create-group-rule --help"}, + Examples: []string{"dws college-contact group create-group-rule --name 测试 --tag-code test"}, }, Parameters: []contract.ParamDecl{ {Name: "name", Property: "input.name", Required: boolPtr(true)}, @@ -5749,7 +5749,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "删除规则", UseWhen: []string{"需要删除规则时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact group delete-group-rule --help"}, + Examples: []string{"dws college-contact group delete-group-rule --rule-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "rule-id", Property: "input.ruleId", Required: boolPtr(true)}, @@ -5807,7 +5807,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "启用规则", UseWhen: []string{"需要启用规则时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact group enable-group-rule --help"}, + Examples: []string{"dws college-contact group enable-group-rule --rule-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "rule-id", Property: "input.ruleId", Required: boolPtr(true)}, @@ -5865,7 +5865,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "停用规则", UseWhen: []string{"需要停用规则时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact group disable-group-rule --help"}, + Examples: []string{"dws college-contact group disable-group-rule --rule-id 12345"}, }, Parameters: []contract.ParamDecl{ {Name: "rule-id", Property: "input.ruleId", Required: boolPtr(true)}, @@ -5920,7 +5920,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "设置规则调度", UseWhen: []string{"需要设置规则调度时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact group set-group-rule-schedule --help"}, + Examples: []string{"dws college-contact group set-group-rule-schedule --format json"}, }, Parameters: []contract.ParamDecl{ {Name: "cron", Property: "input.cron", Required: boolPtr(false)}, @@ -5976,7 +5976,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "立即执行规则", UseWhen: []string{"需要立即执行规则时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact group execute-group-rule --help"}, + Examples: []string{"dws college-contact group execute-group-rule --format json"}, }, Parameters: []contract.ParamDecl{}, }, From c2528f1fc8862dbc9b7ceb11c4239e440502eb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=8D=B7?= Date: Mon, 24 Aug 2026 11:38:00 +0800 Subject: [PATCH 04/33] fix: add CrossPlatformCoverage prefix to edu tests for platform gate The CI platform coverage gate (macOS/Windows) only executes tests matching ^(TestAllShortcuts|TestCrossPlatformCoverage). The edu/college test functions used standard names and were not exercised during the platform coverage run, causing 73.89% changed-code coverage (target 100%). Changes: - Rename all edu/college test functions with TestCrossPlatformCoverage prefix - Add edu products to command_matrix_test.go selected map - Add TestCrossPlatformCoverageOpenSupplementServersIncludesEduEndpoints in pkg/edition to cover the new supplement server entries Local verification: coverage-gate-platform reports 100.0000% (3336 stmts). --- internal/helpers/college_contact_test.go | 18 +++---- internal/helpers/command_matrix_test.go | 2 + internal/helpers/edu_app_test.go | 10 ++-- internal/helpers/edu_contact_test.go | 60 +++++++++++------------ internal/helpers/edu_family_group_test.go | 10 ++-- internal/helpers/edu_group_test.go | 10 ++-- pkg/edition/edition_test.go | 40 +++++++++++++++ 7 files changed, 96 insertions(+), 54 deletions(-) diff --git a/internal/helpers/college_contact_test.go b/internal/helpers/college_contact_test.go index 95e0a26f..03337194 100644 --- a/internal/helpers/college_contact_test.go +++ b/internal/helpers/college_contact_test.go @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" ) -func TestCollegeContactCommand_Structure(t *testing.T) { +func TestCrossPlatformCoverageCollegeContactCommand_Structure(t *testing.T) { cmd := newCollegeContactCommand() if cmd.Name() != "college-contact" { @@ -73,7 +73,7 @@ func TestCollegeContactCommand_Structure(t *testing.T) { } } -func TestCollegeContactCommand_FindPath(t *testing.T) { +func TestCrossPlatformCoverageCollegeContactCommand_FindPath(t *testing.T) { root := &cobra.Command{Use: "dws"} root.AddCommand(newCollegeContactCommand()) @@ -112,7 +112,7 @@ func runDestructiveLeaf(t *testing.T, args ...string) (err error, panicked bool) return err, false } -func TestCollegeContactDestructive_RejectedWithoutYes(t *testing.T) { +func TestCrossPlatformCoverageCollegeContactDestructive_RejectedWithoutYes(t *testing.T) { cases := [][]string{ {"college-contact", "dept", "delete", "--dept-id", "12345"}, {"college-contact", "employee", "remove", "--staff-ids", "S12345,S12346"}, @@ -131,7 +131,7 @@ func TestCollegeContactDestructive_RejectedWithoutYes(t *testing.T) { } } -func TestCollegeContactDestructive_ProceedsWithYes(t *testing.T) { +func TestCrossPlatformCoverageCollegeContactDestructive_ProceedsWithYes(t *testing.T) { cases := [][]string{ {"college-contact", "dept", "delete", "--dept-id", "12345", "--yes"}, {"college-contact", "employee", "remove", "--staff-ids", "S12345", "--yes"}, @@ -147,7 +147,7 @@ func TestCollegeContactDestructive_ProceedsWithYes(t *testing.T) { } } -func TestCollegeContactConfirmDestructive_Helper(t *testing.T) { +func TestCrossPlatformCoverageCollegeContactConfirmDestructive_Helper(t *testing.T) { // 无 yes flag(零值安全兼底)→ 拒绝 bare := &cobra.Command{Use: "x"} if err := collegeContactConfirmDestructive(bare, "删除部门"); err == nil { @@ -176,7 +176,7 @@ func withCollegeContactCaller(t *testing.T) *recruitCaptureCaller { // TestCollegeContactHappyPaths runs every leaf command with required flags only // and with all optional flags populated, expecting a nil error (dry-run preview). -func TestCollegeContactHappyPaths(t *testing.T) { +func TestCrossPlatformCoverageCollegeContactHappyPaths(t *testing.T) { withCollegeContactCaller(t) cases := [][]string{ @@ -301,7 +301,7 @@ func TestCollegeContactHappyPaths(t *testing.T) { // TestCollegeContactValidationErrors exercises every validation-error branch: // missing required flags, non-integer int flags, invalid bool flags, and // empty-after-split CSV lists. Each case must return a non-nil error. -func TestCollegeContactValidationErrors(t *testing.T) { +func TestCrossPlatformCoverageCollegeContactValidationErrors(t *testing.T) { withCollegeContactCaller(t) cases := [][]string{ @@ -522,7 +522,7 @@ func TestCollegeContactValidationErrors(t *testing.T) { // installed (so collegeContactConfirmDestructive does not short-circuit) and // each command is run with valid required flags but without --yes, so the // confirm gate returns an error that the RunE closure propagates. -func TestCollegeContactDestructiveConfirmGate(t *testing.T) { +func TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate(t *testing.T) { caller := &recruitCaptureCaller{dryRun: false} InitDepsForTest(t, caller) deps.Out.w = io.Discard @@ -570,7 +570,7 @@ func withCollegeContactDispatchCaller(t *testing.T) *recruitCaptureCaller { // TestCollegeContactDispatch verifies that representative commands from each // group dispatch to the correct MCP tool with the expected productID and args. -func TestCollegeContactDispatch(t *testing.T) { +func TestCrossPlatformCoverageCollegeContactDispatch(t *testing.T) { type dispatchCase struct { name string args []string diff --git a/internal/helpers/command_matrix_test.go b/internal/helpers/command_matrix_test.go index 2d6aee20..6b2ae020 100644 --- a/internal/helpers/command_matrix_test.go +++ b/internal/helpers/command_matrix_test.go @@ -92,6 +92,8 @@ func TestCrossPlatformCoverageProductCommandMatrixCoverage(t *testing.T) { "ding": true, "doc": true, "drive": true, "live": true, "mail": true, "minutes": true, "oa": true, "report": true, "sheet": true, "todo": true, "wiki": true, + "edu-contact": true, "edu-group": true, "edu-app": true, + "edu-familygroup": true, "college-contact": true, } invocations := 0 for _, root := range NewPublicCommands(&captureRunner{}) { diff --git a/internal/helpers/edu_app_test.go b/internal/helpers/edu_app_test.go index c12a83e4..01c5c9a0 100644 --- a/internal/helpers/edu_app_test.go +++ b/internal/helpers/edu_app_test.go @@ -26,7 +26,7 @@ func runEduApp(t *testing.T, args ...string) error { // TestEduAppHappyPathsFullFlags exercises each leaf command with every flag // populated, so all optional-field branches and the dispatch line are covered. -func TestEduAppHappyPathsFullFlags(t *testing.T) { +func TestCrossPlatformCoverageEduAppHappyPathsFullFlags(t *testing.T) { cases := [][]string{ {"message", "summary-list", "--class-id", "1", "--cid", "c", "--target-role", "guardian", "--status", "0"}, @@ -105,7 +105,7 @@ func TestEduAppHappyPathsFullFlags(t *testing.T) { // TestEduAppZeroPagination drives the pagination defaulting branches (page<=0 / // page-size<=0 / the >0 else arms) that the positive-value happy paths skip. -func TestEduAppZeroPagination(t *testing.T) { +func TestCrossPlatformCoverageEduAppZeroPagination(t *testing.T) { cases := [][]string{ {"report", "by-teacher", "--page", "0", "--limit", "0"}, {"report", "by-student-list", "--class-id", "c", "--student-id", "u1", "--page", "0", "--limit", "0"}, @@ -129,7 +129,7 @@ func TestEduAppZeroPagination(t *testing.T) { } } -func TestEduAppErrorPaths(t *testing.T) { +func TestCrossPlatformCoverageEduAppErrorPaths(t *testing.T) { cases := []struct { name string args []string @@ -241,7 +241,7 @@ func TestEduAppErrorPaths(t *testing.T) { } } -func TestEduAppParseHelpers(t *testing.T) { +func TestCrossPlatformCoverageEduAppParseHelpers(t *testing.T) { if got := eduAppParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" { t.Fatalf("eduAppParseCSV = %#v", got) } @@ -262,7 +262,7 @@ func withEduAppDispatchCaller(t *testing.T) *recruitCaptureCaller { return caller } -func TestEduAppDispatch(t *testing.T) { +func TestCrossPlatformCoverageEduAppDispatch(t *testing.T) { t.Run("message summary-list dispatches get_ai_message_summary_list", func(t *testing.T) { caller := withEduAppDispatchCaller(t) cmd := newEduAppCommand() diff --git a/internal/helpers/edu_contact_test.go b/internal/helpers/edu_contact_test.go index c3b8e5c9..d246b70c 100644 --- a/internal/helpers/edu_contact_test.go +++ b/internal/helpers/edu_contact_test.go @@ -16,7 +16,7 @@ func newTestEduContactRoot() *cobra.Command { // 命令注册测试 — 验证所有子命令路径是否正确注册 // ────────────────────────────────────────────────────────── -func TestEduContactCommandTree(t *testing.T) { +func TestCrossPlatformCoverageEduContactCommandTree(t *testing.T) { root := newTestEduContactRoot() paths := [][]string{ @@ -72,7 +72,7 @@ func executeCommand(root *cobra.Command, args ...string) error { return root.Execute() } -func TestClassSearchByTeacher_MissingName(t *testing.T) { +func TestCrossPlatformCoverageClassSearchByTeacher_MissingName(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "search-by-teacher") if err == nil { @@ -80,7 +80,7 @@ func TestClassSearchByTeacher_MissingName(t *testing.T) { } } -func TestClassAddTeachers_MissingDeptId(t *testing.T) { +func TestCrossPlatformCoverageClassAddTeachers_MissingDeptId(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "add-teachers", "--teacher-user-ids", "uid1") if err == nil { @@ -88,7 +88,7 @@ func TestClassAddTeachers_MissingDeptId(t *testing.T) { } } -func TestClassAddTeachers_MissingTeacherUserIds(t *testing.T) { +func TestCrossPlatformCoverageClassAddTeachers_MissingTeacherUserIds(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345") if err == nil { @@ -96,7 +96,7 @@ func TestClassAddTeachers_MissingTeacherUserIds(t *testing.T) { } } -func TestClassAddTeachers_InvalidIsAdviser(t *testing.T) { +func TestCrossPlatformCoverageClassAddTeachers_InvalidIsAdviser(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "add-teachers", "--dept-id", "12345", "--teacher-user-ids", "uid1", "--is-adviser", "3") if err == nil { @@ -104,7 +104,7 @@ func TestClassAddTeachers_InvalidIsAdviser(t *testing.T) { } } -func TestClassMoveStudent_MissingOriginClassId(t *testing.T) { +func TestCrossPlatformCoverageClassMoveStudent_MissingOriginClassId(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "move-student", "--student-user-ids", "uid1", "--target-class-id", "67890") if err == nil { @@ -112,7 +112,7 @@ func TestClassMoveStudent_MissingOriginClassId(t *testing.T) { } } -func TestClassMoveStudent_MissingStudentUserIds(t *testing.T) { +func TestCrossPlatformCoverageClassMoveStudent_MissingStudentUserIds(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "move-student", "--origin-class-id", "12345", "--target-class-id", "67890") if err == nil { @@ -120,7 +120,7 @@ func TestClassMoveStudent_MissingStudentUserIds(t *testing.T) { } } -func TestClassUpdateStudentMobile_MissingMobile(t *testing.T) { +func TestCrossPlatformCoverageClassUpdateStudentMobile_MissingMobile(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "update-student-mobile", "--dept-id", "12345", "--student-user-id", "uid1") if err == nil { @@ -128,7 +128,7 @@ func TestClassUpdateStudentMobile_MissingMobile(t *testing.T) { } } -func TestClassDeleteStudents_MissingDeptId(t *testing.T) { +func TestCrossPlatformCoverageClassDeleteStudents_MissingDeptId(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "delete-students", "--student-user-ids", "uid1") if err == nil { @@ -136,7 +136,7 @@ func TestClassDeleteStudents_MissingDeptId(t *testing.T) { } } -func TestClassDeleteStudents_MissingStudentUserIds(t *testing.T) { +func TestCrossPlatformCoverageClassDeleteStudents_MissingStudentUserIds(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "delete-students", "--dept-id", "12345") if err == nil { @@ -144,7 +144,7 @@ func TestClassDeleteStudents_MissingStudentUserIds(t *testing.T) { } } -func TestClassAddUnofficialStudent_MissingDeptId(t *testing.T) { +func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingDeptId(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "add-unofficial-student", "--student-staff-ids", "sid1") if err == nil { @@ -152,7 +152,7 @@ func TestClassAddUnofficialStudent_MissingDeptId(t *testing.T) { } } -func TestClassAddUnofficialStudent_MissingStaffIds(t *testing.T) { +func TestCrossPlatformCoverageClassAddUnofficialStudent_MissingStaffIds(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "add-unofficial-student", "--dept-id", "12345") if err == nil { @@ -160,7 +160,7 @@ func TestClassAddUnofficialStudent_MissingStaffIds(t *testing.T) { } } -func TestClassUpdateStudent_MissingClassId(t *testing.T) { +func TestCrossPlatformCoverageClassUpdateStudent_MissingClassId(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "update-student", "--student-user-id", "uid1", "--student-name", "张三", "--append-patriarch") if err == nil { @@ -168,7 +168,7 @@ func TestClassUpdateStudent_MissingClassId(t *testing.T) { } } -func TestClassUpdateStudent_MissingStudentUserId(t *testing.T) { +func TestCrossPlatformCoverageClassUpdateStudent_MissingStudentUserId(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-name", "张三", "--append-patriarch") if err == nil { @@ -176,7 +176,7 @@ func TestClassUpdateStudent_MissingStudentUserId(t *testing.T) { } } -func TestClassUpdateStudent_InvalidPatriarchsJSON(t *testing.T) { +func TestCrossPlatformCoverageClassUpdateStudent_InvalidPatriarchsJSON(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "update-student", "--class-id", "12345", "--student-user-id", "uid1", "--patriarchs", "invalid-json", "--append-patriarch") if err == nil { @@ -184,7 +184,7 @@ func TestClassUpdateStudent_InvalidPatriarchsJSON(t *testing.T) { } } -func TestClassUpdateStudentNumber_MissingStudentNumber(t *testing.T) { +func TestCrossPlatformCoverageClassUpdateStudentNumber_MissingStudentNumber(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "update-student-number", "--class-id", "12345", "--student-user-id", "uid1") if err == nil { @@ -192,7 +192,7 @@ func TestClassUpdateStudentNumber_MissingStudentNumber(t *testing.T) { } } -func TestClassUpdateInfo_MissingClassId(t *testing.T) { +func TestCrossPlatformCoverageClassUpdateInfo_MissingClassId(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "update-info", "--nick", "火箭班") if err == nil { @@ -200,7 +200,7 @@ func TestClassUpdateInfo_MissingClassId(t *testing.T) { } } -func TestClassUpdateInfo_GroupNameWithoutConversationId(t *testing.T) { +func TestCrossPlatformCoverageClassUpdateInfo_GroupNameWithoutConversationId(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "update-info", "--class-id", "12345", "--group-name", "测试群") if err == nil { @@ -208,7 +208,7 @@ func TestClassUpdateInfo_GroupNameWithoutConversationId(t *testing.T) { } } -func TestClassDeleteTeacher_MissingTeacherUserId(t *testing.T) { +func TestCrossPlatformCoverageClassDeleteTeacher_MissingTeacherUserId(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "delete-teacher", "--class-id", "12345") if err == nil { @@ -216,7 +216,7 @@ func TestClassDeleteTeacher_MissingTeacherUserId(t *testing.T) { } } -func TestClassModifyStudentInfo_MissingBothNickAndPatriarch(t *testing.T) { +func TestCrossPlatformCoverageClassModifyStudentInfo_MissingBothNickAndPatriarch(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1") if err == nil { @@ -224,7 +224,7 @@ func TestClassModifyStudentInfo_MissingBothNickAndPatriarch(t *testing.T) { } } -func TestClassModifyStudentInfo_PatriarchWithoutRelation(t *testing.T) { +func TestCrossPlatformCoverageClassModifyStudentInfo_PatriarchWithoutRelation(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "modify-student-info", "--dept-id", "12345", "--target-user-id", "uid1", "--patriarch-user-id", "pid1") if err == nil { @@ -232,7 +232,7 @@ func TestClassModifyStudentInfo_PatriarchWithoutRelation(t *testing.T) { } } -func TestClassAddStudent_MissingStudentName(t *testing.T) { +func TestCrossPlatformCoverageClassAddStudent_MissingStudentName(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-mobile", "13800138000") if err == nil { @@ -240,7 +240,7 @@ func TestClassAddStudent_MissingStudentName(t *testing.T) { } } -func TestClassAddStudent_MissingMobile(t *testing.T) { +func TestCrossPlatformCoverageClassAddStudent_MissingMobile(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三") if err == nil { @@ -248,7 +248,7 @@ func TestClassAddStudent_MissingMobile(t *testing.T) { } } -func TestClassAddStudent_InvalidMotherJSON(t *testing.T) { +func TestCrossPlatformCoverageClassAddStudent_InvalidMotherJSON(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "class", "add-student", "--dept-id", "12345", "--student-name", "张三", "--mother", "bad-json") if err == nil { @@ -256,7 +256,7 @@ func TestClassAddStudent_InvalidMotherJSON(t *testing.T) { } } -func TestTeacherUpdateCourse_MissingTeacherClassInfos(t *testing.T) { +func TestCrossPlatformCoverageTeacherUpdateCourse_MissingTeacherClassInfos(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "teacher", "update-course") if err == nil { @@ -264,7 +264,7 @@ func TestTeacherUpdateCourse_MissingTeacherClassInfos(t *testing.T) { } } -func TestTeacherUpdateCourse_InvalidJSON(t *testing.T) { +func TestCrossPlatformCoverageTeacherUpdateCourse_InvalidJSON(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "not-json") if err == nil { @@ -272,7 +272,7 @@ func TestTeacherUpdateCourse_InvalidJSON(t *testing.T) { } } -func TestTeacherUpdateCourse_EmptyArray(t *testing.T) { +func TestCrossPlatformCoverageTeacherUpdateCourse_EmptyArray(t *testing.T) { root := newTestEduContactRoot() err := executeCommand(root, "teacher", "update-course", "--teacher-class-infos", "[]") if err == nil { @@ -296,7 +296,7 @@ func withEduContactCaller(t *testing.T) *recruitCaptureCaller { return caller } -func TestEduContactHappyPaths(t *testing.T) { +func TestCrossPlatformCoverageEduContactHappyPaths(t *testing.T) { withEduContactCaller(t) cases := [][]string{ // school @@ -371,7 +371,7 @@ func TestEduContactHappyPaths(t *testing.T) { // TestEduContactErrorPathsRemaining covers validation branches not exercised by // the existing error tests, ensuring 100% changed-code coverage. -func TestEduContactErrorPathsRemaining(t *testing.T) { +func TestCrossPlatformCoverageEduContactErrorPathsRemaining(t *testing.T) { cases := []struct { name string args []string @@ -447,7 +447,7 @@ func withEduContactDispatchCaller(t *testing.T) *recruitCaptureCaller { return caller } -func TestEduContactDispatch(t *testing.T) { +func TestCrossPlatformCoverageEduContactDispatch(t *testing.T) { t.Run("class detail dispatches get_class_detail with deptId", func(t *testing.T) { caller := withEduContactDispatchCaller(t) root := newEduContactCommand() diff --git a/internal/helpers/edu_family_group_test.go b/internal/helpers/edu_family_group_test.go index c4f5f73e..59d7eb2c 100644 --- a/internal/helpers/edu_family_group_test.go +++ b/internal/helpers/edu_family_group_test.go @@ -17,7 +17,7 @@ func withEduFamilyGroupCaller(t *testing.T) *recruitCaptureCaller { return caller } -func TestEduFamilyGroupHappyPaths(t *testing.T) { +func TestCrossPlatformCoverageEduFamilyGroupHappyPaths(t *testing.T) { cases := [][]string{ {"group", "check-exists", "--uid", "1", "--group-name", "小明一家"}, {"group", "list-children", "--uid", "1"}, @@ -45,7 +45,7 @@ func TestEduFamilyGroupHappyPaths(t *testing.T) { } } -func TestEduFamilyGroupErrorPaths(t *testing.T) { +func TestCrossPlatformCoverageEduFamilyGroupErrorPaths(t *testing.T) { cases := []struct { name string args []string @@ -94,7 +94,7 @@ func TestEduFamilyGroupErrorPaths(t *testing.T) { } } -func TestEduFamilyGroupValidateChildren(t *testing.T) { +func TestCrossPlatformCoverageEduFamilyGroupValidateChildren(t *testing.T) { valid := []any{map[string]any{ "name": "小明", "students": []any{map[string]any{"corpId": "c", "staffId": "s"}}, @@ -124,7 +124,7 @@ func TestEduFamilyGroupValidateChildren(t *testing.T) { } } -func TestEduFamilyGroupValidateStudents(t *testing.T) { +func TestCrossPlatformCoverageEduFamilyGroupValidateStudents(t *testing.T) { valid := []any{map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"}} if err := eduFamilyGroupValidateStudents(valid); err != nil { t.Fatalf("valid students error = %v", err) @@ -156,7 +156,7 @@ func withEduFamilyGroupDispatchCaller(t *testing.T) *recruitCaptureCaller { return caller } -func TestEduFamilyGroupDispatch(t *testing.T) { +func TestCrossPlatformCoverageEduFamilyGroupDispatch(t *testing.T) { t.Run("check-exists", func(t *testing.T) { caller := withEduFamilyGroupDispatchCaller(t) cmd := newEduFamilyGroupCommand() diff --git a/internal/helpers/edu_group_test.go b/internal/helpers/edu_group_test.go index c8f92095..d96038a9 100644 --- a/internal/helpers/edu_group_test.go +++ b/internal/helpers/edu_group_test.go @@ -20,7 +20,7 @@ func withEduGroupCaller(t *testing.T) *recruitCaptureCaller { return caller } -func TestEduGroupHappyPaths(t *testing.T) { +func TestCrossPlatformCoverageEduGroupHappyPaths(t *testing.T) { cases := [][]string{ {"student-group", "info", "--dept-id", "123"}, {"student-group", "exists", "--dept-id", "123"}, @@ -49,7 +49,7 @@ func TestEduGroupHappyPaths(t *testing.T) { } } -func TestEduGroupMissingRequiredFlags(t *testing.T) { +func TestCrossPlatformCoverageEduGroupMissingRequiredFlags(t *testing.T) { // Each dept-id command must reject an absent --dept-id, covering its own // error branch as well as the shared eduGroupRequiredIntFlag empty case. deptIDCommands := [][]string{ @@ -76,7 +76,7 @@ func TestEduGroupMissingRequiredFlags(t *testing.T) { } } -func TestEduGroupFlagValidation(t *testing.T) { +func TestCrossPlatformCoverageEduGroupFlagValidation(t *testing.T) { t.Run("non-integer dept-id", func(t *testing.T) { withEduGroupCaller(t) cmd := newEduGroupCommand() @@ -141,7 +141,7 @@ func TestEduGroupFlagValidation(t *testing.T) { }) } -func TestEduGroupParseHelpers(t *testing.T) { +func TestCrossPlatformCoverageEduGroupParseHelpers(t *testing.T) { if got := eduGroupParseCSV(" a , , b "); len(got) != 2 || got[0] != "a" || got[1] != "b" { t.Fatalf("eduGroupParseCSV = %#v", got) } @@ -164,7 +164,7 @@ func withEduGroupDispatchCaller(t *testing.T) *recruitCaptureCaller { return caller } -func TestEduGroupDispatch(t *testing.T) { +func TestCrossPlatformCoverageEduGroupDispatch(t *testing.T) { t.Run("student-group info dispatches get_class_group_info", func(t *testing.T) { caller := withEduGroupDispatchCaller(t) cmd := newEduGroupCommand() diff --git a/pkg/edition/edition_test.go b/pkg/edition/edition_test.go index 7418836e..9da50a9f 100644 --- a/pkg/edition/edition_test.go +++ b/pkg/edition/edition_test.go @@ -137,3 +137,43 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { t.Fatal("openSupplementServers() missing explicitly wired recruit endpoint") } } + +func TestCrossPlatformCoverageOpenSupplementServersIncludesEduEndpoints(t *testing.T) { + servers := openSupplementServers() + byID := make(map[string]ServerInfo, len(servers)) + for _, s := range servers { + byID[s.ID] = s + } + + edu := []struct { + id string + endpoint string + prefixes []string + }{ + {"edu-contact", "https://mcp-gw.dingtalk.com/server/d24759cc1c6e424e2de4e9901ea0202136e6707991ffc33b473878ec1cd688a2", []string{"edu-contact", "edu"}}, + {"edu-group", "https://mcp-gw.dingtalk.com/server/14624b71ac9bc1a03b1b60e5b0403a48b346361f86cc9f555f98f89eb383875a", []string{"edu-group"}}, + {"edu-app", "https://mcp-gw.dingtalk.com/server/905eef591d16e2a1d95b235bcc780ce2fadb6ebe1f25648a279f8a2d97907a1e", []string{"edu-app"}}, + {"edu-familygroup", "https://mcp-gw.dingtalk.com/server/1cd153fb5296df340507c3e9ee20c938f9feeefec3147e9cc32317032f1a2944", []string{"edu-familygroup"}}, + {"college-contact", "https://mcp-gw.dingtalk.com/server/45bb310b388b9c39e0b80e08236782880cb51ad536e1292f9a40933c428a7474", []string{"college-contact"}}, + } + + for _, want := range edu { + got, ok := byID[want.id] + if !ok { + t.Errorf("openSupplementServers() missing edu endpoint %q", want.id) + continue + } + if got.Endpoint != want.endpoint { + t.Errorf("%s endpoint = %q, want %q", want.id, got.Endpoint, want.endpoint) + } + if len(got.Prefixes) != len(want.prefixes) { + t.Errorf("%s prefixes length = %d, want %d", want.id, len(got.Prefixes), len(want.prefixes)) + continue + } + for i, p := range want.prefixes { + if got.Prefixes[i] != p { + t.Errorf("%s prefixes[%d] = %q, want %q", want.id, i, got.Prefixes[i], p) + } + } + } +} From 232d9dee6eb154c34fe81e113c4884bfb30c1b41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=8D=B7?= Date: Mon, 24 Aug 2026 13:07:38 +0800 Subject: [PATCH 05/33] fix: remove edu products from command matrix to avoid Windows timeout The command matrix test with 5 additional edu products (153 leaf commands) exceeds the 10-minute go test timeout on Windows CI runners. The dedicated TestCrossPlatformCoverage* tests in individual edu test files already provide 100% changed-code coverage without the matrix. --- internal/helpers/command_matrix_test.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/internal/helpers/command_matrix_test.go b/internal/helpers/command_matrix_test.go index 6b2ae020..2d6aee20 100644 --- a/internal/helpers/command_matrix_test.go +++ b/internal/helpers/command_matrix_test.go @@ -92,8 +92,6 @@ func TestCrossPlatformCoverageProductCommandMatrixCoverage(t *testing.T) { "ding": true, "doc": true, "drive": true, "live": true, "mail": true, "minutes": true, "oa": true, "report": true, "sheet": true, "todo": true, "wiki": true, - "edu-contact": true, "edu-group": true, "edu-app": true, - "edu-familygroup": true, "college-contact": true, } invocations := 0 for _, root := range NewPublicCommands(&captureRunner{}) { From f9277ae1d332698783749c57b4a3fb501fb94b6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=8D=B7?= Date: Mon, 24 Aug 2026 13:30:18 +0800 Subject: [PATCH 06/33] fix: use standard confirmation framework for college-contact destructive commands Remove custom collegeContactConfirmDestructive function and rely on the standard framework Confirmation: "user_required" in LeafSpec. This ensures destructive commands return the proper category:validation/code:3 error instead of category:internal/code:5. Also adds run_all_edu_commands_test.go covering all 153 leaf commands across 5 edu/college products. --- internal/helpers/college_contact.go | 73 +---- internal/helpers/college_contact_test.go | 20 +- internal/helpers/run_all_edu_commands_test.go | 280 ++++++++++++++++++ 3 files changed, 288 insertions(+), 85 deletions(-) create mode 100644 internal/helpers/run_all_edu_commands_test.go diff --git a/internal/helpers/college_contact.go b/internal/helpers/college_contact.go index 06b0bb9e..ce0321ab 100644 --- a/internal/helpers/college_contact.go +++ b/internal/helpers/college_contact.go @@ -700,9 +700,6 @@ func newCollegeContactCommand() *cobra.Command { if err != nil { return fmt.Errorf("--dept-id 须为整数: %w", err) } - if err := collegeContactConfirmDestructive(cmd, "删除部门"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "delete_college_contact_dept", map[string]any{ "input": map[string]any{"deptId": deptID}, }) @@ -1988,9 +1985,6 @@ func newCollegeContactCommand() *cobra.Command { } input["staffIds"] = staffIDs - if err := collegeContactConfirmDestructive(cmd, "移除员工"); err != nil { - return err - } // 可选 int64 for _, pair := range []struct { @@ -3316,9 +3310,6 @@ func newCollegeContactCommand() *cobra.Command { if err != nil { return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) } - if err := collegeContactConfirmDestructive(cmd, "删除校友部门"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "delete_alumni_dept", map[string]any{ "input": map[string]any{"alumniDeptId": alumniDeptId}, }) @@ -3705,9 +3696,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) } - if err := collegeContactConfirmDestructive(cmd, "删除校友"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "delete_alumnus", map[string]any{ "input": map[string]any{ @@ -3795,9 +3783,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--staff-ids 不能为空列表") } - if err := collegeContactConfirmDestructive(cmd, "取消校友邀请"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "delete_alumni_invite_record", map[string]any{ "input": map[string]any{ @@ -3930,9 +3915,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) } - if err := collegeContactConfirmDestructive(cmd, "解散校友群"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "disband_alumni_group", map[string]any{ "input": map[string]any{ @@ -4814,9 +4796,6 @@ func newCollegeContactCommand() *cobra.Command { input["requestNo"] = strings.TrimSpace(v) } - if err := collegeContactConfirmDestructive(cmd, "提交毕业"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "commit_graduate", map[string]any{ "input": input, @@ -4827,7 +4806,7 @@ func newCollegeContactCommand() *cobra.Command { DeclareLeafMetadata(commitGraduateCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "non_idempotent", + Confirmation: "user_required", Idempotency: "non_idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -4895,9 +4874,6 @@ func newCollegeContactCommand() *cobra.Command { input["requestNo"] = strings.TrimSpace(v) } - if err := collegeContactConfirmDestructive(cmd, "全部毕业"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "all_graduate", map[string]any{ "input": input, @@ -4908,7 +4884,7 @@ func newCollegeContactCommand() *cobra.Command { DeclareLeafMetadata(allGraduateCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "non_idempotent", + Confirmation: "user_required", Idempotency: "non_idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -4983,9 +4959,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--staff-ids 不能为空列表") } - if err := collegeContactConfirmDestructive(cmd, "批量毕业"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "batch_graduate", map[string]any{ "input": map[string]any{ @@ -4999,7 +4972,7 @@ func newCollegeContactCommand() *cobra.Command { DeclareLeafMetadata(batchGraduateCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "non_idempotent", + Confirmation: "user_required", Idempotency: "non_idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -5074,9 +5047,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--staff-ids 不能为空列表") } - if err := collegeContactConfirmDestructive(cmd, "删除并毕业"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "delete_and_graduate", map[string]any{ "input": map[string]any{ @@ -5165,9 +5135,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--staff-ids 不能为空列表") } - if err := collegeContactConfirmDestructive(cmd, "批量删除待毕业学生"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "batch_delete_pending", map[string]any{ "input": map[string]any{ @@ -5267,9 +5234,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--graduate-year 须为整数: %w", err) } - if err := collegeContactConfirmDestructive(cmd, "批量更新待毕业学生"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "batch_update_pending", map[string]any{ "input": map[string]any{ @@ -5284,7 +5248,7 @@ func newCollegeContactCommand() *cobra.Command { DeclareLeafMetadata(batchUpdatePendingCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "non_idempotent", + Confirmation: "user_required", Idempotency: "non_idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -5363,9 +5327,6 @@ func newCollegeContactCommand() *cobra.Command { input["requestNo"] = strings.TrimSpace(v) } - if err := collegeContactConfirmDestructive(cmd, "提交恢复"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "commit_restore", map[string]any{ "input": input, @@ -5376,7 +5337,7 @@ func newCollegeContactCommand() *cobra.Command { DeclareLeafMetadata(commitRestoreCmd, LeafSpec{ Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", - Confirmation: "not_required", Idempotency: "non_idempotent", + Confirmation: "user_required", Idempotency: "non_idempotent", }, Contract: LeafContract{ Identity: contract.ToolIdentitySpec{ @@ -5716,9 +5677,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--rule-id 须为整数: %w", err) } - if err := collegeContactConfirmDestructive(cmd, "删除规则"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "delete_group_rule", map[string]any{ "input": map[string]any{"ruleId": ruleID}, @@ -5943,9 +5901,6 @@ func newCollegeContactCommand() *cobra.Command { dws college-contact group execute-group-rule -y dws college-contact group execute-group-rule -y -f json`, RunE: func(cmd *cobra.Command, args []string) error { - if err := collegeContactConfirmDestructive(cmd, "立即执行规则"); err != nil { - return err - } return callMCPToolOnServer("college-contact", "execute_group_rule", map[string]any{ "input": map[string]any{}, @@ -5992,21 +5947,3 @@ func newCollegeContactCommand() *cobra.Command { return root } - -// collegeContactConfirmDestructive 是不可逆操作(删除部门/移除员工)的安全门禁: -// - 全局 --dry-run 仅预览不执行,直接放行; -// - 显式传入全局 --yes/-y 视为已确认,放行; -// - 其余情况拒绝执行,避免误操作产生不可逆变更。 -// -// 全局 flag 由核心框架注册在 rootCmd 上,cobra 执行时会将 persistent flag -// 合并进叶子命令的 Flags();若 flag 不存在(如单测环境),GetBool 返回零值, -// 默认拒绝执行,保持安全兼底。 -func collegeContactConfirmDestructive(cmd *cobra.Command, action string) error { - if deps != nil && deps.Caller != nil && deps.Caller.DryRun() { - return nil - } - if yes, _ := cmd.Flags().GetBool("yes"); yes { - return nil - } - return fmt.Errorf("%s为不可逆操作:请先向用户展示操作摘要并获得明确同意,确认后追加 --yes 执行,或使用 --dry-run 预览", action) -} diff --git a/internal/helpers/college_contact_test.go b/internal/helpers/college_contact_test.go index 03337194..3201aa3f 100644 --- a/internal/helpers/college_contact_test.go +++ b/internal/helpers/college_contact_test.go @@ -140,27 +140,13 @@ func TestCrossPlatformCoverageCollegeContactDestructive_ProceedsWithYes(t *testi err, panicked := runDestructiveLeaf(t, args...) if !panicked { // 未 panic 意味着未到达 MCP 调用层;若返回的仍是门禁错误则为拦截失败 - if err != nil && strings.Contains(err.Error(), "不可逆操作") { + if err != nil && strings.Contains(err.Error(), "需要用户确认") { t.Fatalf("%v: 已传 --yes 仍被门禁拦截: %v", args, err) } } } } -func TestCrossPlatformCoverageCollegeContactConfirmDestructive_Helper(t *testing.T) { - // 无 yes flag(零值安全兼底)→ 拒绝 - bare := &cobra.Command{Use: "x"} - if err := collegeContactConfirmDestructive(bare, "删除部门"); err == nil { - t.Error("无 --yes 应拒绝执行") - } - - // yes=true → 放行 - withYes := &cobra.Command{Use: "x"} - withYes.Flags().Bool("yes", true, "") - if err := collegeContactConfirmDestructive(withYes, "删除部门"); err != nil { - t.Errorf("已传 --yes 应放行,got: %v", err) - } -} // withCollegeContactCaller installs a dry-run capture caller so happy-path // command execution exercises each RunE up to the callMCPToolOnServer dispatch @@ -551,8 +537,8 @@ func TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate(t *testing.T) t.Errorf("%v: expected confirm-gate error without --yes, got nil", args) continue } - if !strings.Contains(err.Error(), "不可逆操作") { - t.Errorf("%v: expected 不可逆操作 gate error, got: %v", args, err) + if !strings.Contains(err.Error(), "需要用户确认") { + t.Errorf("%v: expected confirmation gate error, got: %v", args, err) } } } diff --git a/internal/helpers/run_all_edu_commands_test.go b/internal/helpers/run_all_edu_commands_test.go new file mode 100644 index 00000000..3c645141 --- /dev/null +++ b/internal/helpers/run_all_edu_commands_test.go @@ -0,0 +1,280 @@ +package helpers + +import ( + "fmt" + "io" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// TestRunAllEduCommands 逐条执行 5 个教育产品的全部 153 条叶子命令(dry-run 模式), +// 并打印每条命令对应的 MCP 工具名和参数。相当于在终端逐一执行 dws --dry-run。 +func TestRunAllEduCommands(t *testing.T) { + type cmdCase struct { + product string + args []string + } + + allCmds := []cmdCase{ + // ═══════════════════════════════════════════════════════════ + // edu-contact: 26 commands + // ═══════════════════════════════════════════════════════════ + // school (6) + {"edu-contact", []string{"school", "roles"}}, + {"edu-contact", []string{"school", "structure"}}, + {"edu-contact", []string{"school", "periods"}}, + {"edu-contact", []string{"school", "type"}}, + {"edu-contact", []string{"school", "stats", "--statistics-type", "1"}}, + {"edu-contact", []string{"school", "class-list"}}, + // class (18) + {"edu-contact", []string{"class", "detail", "--dept-id", "12345"}}, + {"edu-contact", []string{"class", "students", "--dept-id", "12345"}}, + {"edu-contact", []string{"class", "teachers", "--dept-id", "12345"}}, + {"edu-contact", []string{"class", "same-name", "--dept-id", "12345"}}, + {"edu-contact", []string{"class", "user-role", "--dept-id", "12345"}}, + {"edu-contact", []string{"class", "search-by-name", "--query-type", "student", "--name", "张三"}}, + {"edu-contact", []string{"class", "headmaster", "--class-name", "一年级1班"}}, + {"edu-contact", []string{"class", "search-by-teacher", "--name", "张老师"}}, + {"edu-contact", []string{"class", "update-student", "--class-id", "123", "--student-user-id", "u1"}}, + {"edu-contact", []string{"class", "add-student", "--dept-id", "123", "--student-name", "张三", "--student-mobile", "13800138000"}}, + {"edu-contact", []string{"class", "modify-student-info", "--dept-id", "123", "--target-user-id", "u1", "--nick", "小明"}}, + {"edu-contact", []string{"class", "delete-teacher", "--class-id", "123", "--teacher-user-id", "u1"}}, + {"edu-contact", []string{"class", "update-info", "--class-id", "123", "--nick", "火箭班"}}, + {"edu-contact", []string{"class", "update-student-number", "--class-id", "123", "--student-user-id", "u1", "--student-number", "S001"}}, + {"edu-contact", []string{"class", "add-unofficial-student", "--dept-id", "123", "--student-staff-ids", "s1,s2"}}, + {"edu-contact", []string{"class", "delete-students", "--dept-id", "123", "--student-user-ids", "u1,u2"}}, + {"edu-contact", []string{"class", "update-student-mobile", "--dept-id", "123", "--student-user-id", "u1", "--mobile", "13800138000"}}, + {"edu-contact", []string{"class", "move-student", "--student-user-ids", "u1,u2", "--origin-class-id", "123", "--target-class-id", "456"}}, + {"edu-contact", []string{"class", "add-teachers", "--dept-id", "123", "--teacher-user-ids", "u1,u2"}}, + // family (2) + {"edu-contact", []string{"family", "children"}}, + {"edu-contact", []string{"family", "parents"}}, + // teacher (2) + {"edu-contact", []string{"teacher", "classes"}}, + {"edu-contact", []string{"teacher", "update-course", "--teacher-class-infos", `[{"classId":123,"courseCode":"MATH","courseName":"数学"}]`}}, + + // ═══════════════════════════════════════════════════════════ + // edu-group: 14 commands + // ═══════════════════════════════════════════════════════════ + // student-group (7) + {"edu-group", []string{"student-group", "info", "--dept-id", "12345"}}, + {"edu-group", []string{"student-group", "exists", "--dept-id", "12345"}}, + {"edu-group", []string{"student-group", "members", "--dept-id", "12345"}}, + {"edu-group", []string{"student-group", "is-in", "--dept-id", "12345"}}, + {"edu-group", []string{"student-group", "conversation", "--dept-id", "12345"}}, + {"edu-group", []string{"student-group", "create", "--dept-id", "12345"}}, + {"edu-group", []string{"student-group", "disband", "--dept-id", "12345"}}, + // class-group (4) + {"edu-group", []string{"class-group", "conversation-id", "--dept-id", "12345"}}, + {"edu-group", []string{"class-group", "conversation", "--dept-id", "12345"}}, + {"edu-group", []string{"class-group", "exists", "--dept-id", "12345"}}, + {"edu-group", []string{"class-group", "list-by-cids", "--conversation-ids", "cid1,cid2,cid3"}}, + // batch (3) + {"edu-group", []string{"batch", "check-student-group", "--class-ids", "12345,67890"}}, + {"edu-group", []string{"batch", "get-class-groups", "--class-ids", "12345,67890"}}, + {"edu-group", []string{"batch", "create-student-groups"}}, + + // ═══════════════════════════════════════════════════════════ + // edu-app: 42 commands + // ═══════════════════════════════════════════════════════════ + // message (1) + {"edu-app", []string{"message", "summary-list", "--class-id", "1", "--cid", "c1", "--target-role", "guardian", "--status", "0"}}, + // task (3) + {"edu-app", []string{"task", "publish-list"}}, + {"edu-app", []string{"task", "all-list", "--biz-id", "1"}}, + {"edu-app", []string{"task", "student-list", "--students", `[{"userId":"u1","bizId":"1"}]`}}, + // report (5) + {"edu-app", []string{"report", "get", "--ids", "1001,1002"}}, + {"edu-app", []string{"report", "by-teacher"}}, + {"edu-app", []string{"report", "by-class", "--report-id", "1001", "--class-id", "12345"}}, + {"edu-app", []string{"report", "by-student-list", "--class-id", "12345", "--student-id", "u1"}}, + {"edu-app", []string{"report", "by-student-detail", "--report-id", "1001", "--student-id", "u1", "--class-id", "12345"}}, + // notice (7) + {"edu-app", []string{"notice", "confirm", "--notice-id", "n1", "--student-id", "u1"}}, + {"edu-app", []string{"notice", "create", "--identifer", "org1-staff1-uuid", "--content", "明天放假"}}, + {"edu-app", []string{"notice", "delete", "--notice-id", "12345"}}, + {"edu-app", []string{"notice", "list-by-teacher"}}, + {"edu-app", []string{"notice", "get", "--notice-id", "12345"}}, + {"edu-app", []string{"notice", "confirm-status", "--notice-id", "12345", "--class-id", "c1"}}, + {"edu-app", []string{"notice", "list-by-student", "--student-id", "u1", "--class-id", "c1"}}, + // circle (1) + {"edu-app", []string{"circle", "posts", "--class-id", "12345", "--student-id", "u1", "--target-role", "guardian"}}, + // card (5) + {"edu-app", []string{"card", "update", "--card-id", "1", "--identifier", "org1-staff1-uuid", "--title", "新标题"}}, + {"edu-app", []string{"card", "end", "--card-id", "1"}}, + {"edu-app", []string{"card", "list", "--status", "UNFINISH"}}, + {"edu-app", []string{"card", "user-statistic", "--card-id", "1", "--task-code", "code1", "--class-id", "cid1"}}, + {"edu-app", []string{"card", "finish-info", "--card-id", "1", "--card-biz-id", "bid1"}}, + // diploma (9) + {"edu-app", []string{"diploma", "create", "--identifier", "org1-staff1-uuid", "--content", "三好学生", "--user-name", "张三"}}, + {"edu-app", []string{"diploma", "read", "--diploma-id", "1"}}, + {"edu-app", []string{"diploma", "list-by-teacher"}}, + {"edu-app", []string{"diploma", "get", "--diploma-id", "1"}}, + {"edu-app", []string{"diploma", "statistics", "--diploma-id", "1"}}, + {"edu-app", []string{"diploma", "detail", "--diploma-id", "1"}}, + {"edu-app", []string{"diploma", "list-by-student", "--student-id", "u1", "--class-id", "c1"}}, + {"edu-app", []string{"diploma", "student-detail", "--diploma-id", "1", "--student-id", "u1", "--class-id", "c1"}}, + {"edu-app", []string{"diploma", "delete", "--diploma-id", "1"}}, + // homework (11) + {"edu-app", []string{"homework", "create", "--identifier", "org1-staff1-uuid", "--hw-content", "完成练习册第3页"}}, + {"edu-app", []string{"homework", "delete", "--homework-id", "1"}}, + {"edu-app", []string{"homework", "submit", "--hw-content-detail-id", "1"}}, + {"edu-app", []string{"homework", "get", "--homework-id", "1"}}, + {"edu-app", []string{"homework", "class-by-homework", "--homework-id", "1"}}, + {"edu-app", []string{"homework", "class-detail", "--homework-id", "1", "--class-id", "c1", "--user-name", "张老师"}}, + {"edu-app", []string{"homework", "submit-statistics", "--homework-id", "1", "--class-id", "c1"}}, + {"edu-app", []string{"homework", "list-by-student", "--student-id", "u1", "--class-id", "c1", "--user-name", "张三"}}, + {"edu-app", []string{"homework", "student-detail", "--homework-id", "1", "--student-id", "u1", "--class-id", "c1"}}, + {"edu-app", []string{"homework", "list-by-teacher"}}, + {"edu-app", []string{"homework", "create-comment", "--comment", "做得很好", "--hw-content-detail-id", "1"}}, + + // ═══════════════════════════════════════════════════════════ + // edu-familygroup: 6 commands + // ═══════════════════════════════════════════════════════════ + // group (2) + {"edu-familygroup", []string{"group", "check-exists", "--uid", "12345", "--group-name", "小明一家"}}, + {"edu-familygroup", []string{"group", "list-children", "--uid", "12345"}}, + // manage (4) + {"edu-familygroup", []string{"manage", "create", "--uid", "12345", "--children", `[{"name":"小明","students":[{"corpId":"corp1","staffId":"staff1"}]}]`}}, + {"edu-familygroup", []string{"manage", "invite-parent", "--org-id", "12345", "--uid", "67890", "--mobile", "13800138000"}}, + {"edu-familygroup", []string{"manage", "add-child", "--org-id", "12345", "--uid", "67890", "--name", "小红", "--mobile", "13900139000"}}, + {"edu-familygroup", []string{"manage", "toggle-app", "--org-id", "12345", "--uid", "67890", "--child-staff-id", "staff1", "--app-type", "XIAOTIANDI", "--open", "true"}}, + + // ═══════════════════════════════════════════════════════════ + // college-contact: 65 commands + // ═══════════════════════════════════════════════════════════ + // dept (9) + {"college-contact", []string{"dept", "get-standard-structure"}}, + {"college-contact", []string{"dept", "get-detail", "--dept-id", "12345"}}, + {"college-contact", []string{"dept", "get-chain", "--dept-id", "12345"}}, + {"college-contact", []string{"dept", "search", "--dept-id", "12345", "--keyword", "计算机"}}, + {"college-contact", []string{"dept", "create", "--super-id", "1", "--stru-dept-id", "2", "--name", "计算机学院", "--dept-type", "COLLEGE", "--create-dept-group", "true"}}, + {"college-contact", []string{"dept", "update", "--dept-id", "12345", "--dept-type", "COLLEGE"}}, + {"college-contact", []string{"dept", "delete", "--dept-id", "12345"}}, + {"college-contact", []string{"dept", "batch-update-type", "--dept-ids", "1,2,3", "--target-dept-type", "COLLEGE"}}, + {"college-contact", []string{"dept", "overview"}}, + // employee (11) + {"college-contact", []string{"employee", "get-detail", "--staff-id", "staff001"}}, + {"college-contact", []string{"employee", "add", "--emp-type", "TEACHER", "--main-dept-id", "12345", "--exclusive-account", "false"}}, + {"college-contact", []string{"employee", "remove", "--staff-ids", "s1,s2"}}, + {"college-contact", []string{"employee", "change-type", "--staff-id", "staff001", "--emp-type", "STUDENT"}}, + {"college-contact", []string{"employee", "change-dept", "--staff-id", "staff001", "--target-dept-id", "67890"}}, + {"college-contact", []string{"employee", "send-active-sms", "--dept-id", "12345"}}, + {"college-contact", []string{"employee", "list-employees", "--dept-id", "12345"}}, + {"college-contact", []string{"employee", "list-unaccepted", "--dept-id", "12345"}}, + {"college-contact", []string{"employee", "list-unactive", "--dept-id", "12345"}}, + {"college-contact", []string{"employee", "upgrade-status"}}, + {"college-contact", []string{"employee", "start-upgrade"}}, + // alumni (20) + {"college-contact", []string{"alumni", "get-dept-tree", "--alumni-dept-id", "12345"}}, + {"college-contact", []string{"alumni", "get-info", "--alumni-dept-id", "12345"}}, + {"college-contact", []string{"alumni", "list", "--alumni-dept-id", "12345", "--order-field", "NAME", "--ordering", "ASC"}}, + {"college-contact", []string{"alumni", "query", "--staff-id", "staff001"}}, + {"college-contact", []string{"alumni", "search", "--keyword", "张三"}}, + {"college-contact", []string{"alumni", "list-unaccepted", "--alumni-dept-id", "12345"}}, + {"college-contact", []string{"alumni", "get-group", "--alumni-dept-id", "12345"}}, + {"college-contact", []string{"alumni", "create-dept", "--alumni-dept-id", "12345", "--dept-name", "2020届"}}, + {"college-contact", []string{"alumni", "update-dept", "--alumni-dept-id", "12345", "--dept-name", "2020届计算机"}}, + {"college-contact", []string{"alumni", "delete-dept", "--alumni-dept-id", "12345"}}, + {"college-contact", []string{"alumni", "update-managers", "--alumni-dept-id", "12345", "--admin-user-ids", "u1,u2"}}, + {"college-contact", []string{"alumni", "add-alumnus", "--dept-ids", "1,2", "--name", "张三", "--mobile", "13800138000"}}, + {"college-contact", []string{"alumni", "update-alumnus", "--dept-ids", "1,2", "--staff-id", "s1", "--name", "张三"}}, + {"college-contact", []string{"alumni", "remove-alumnus", "--staff-id", "s1", "--alumni-dept-id", "12345"}}, + {"college-contact", []string{"alumni", "cancel-invite", "--alumni-dept-id", "12345", "--staff-ids", "s1,s2"}}, + {"college-contact", []string{"alumni", "create-group", "--alumni-dept-id", "12345"}}, + {"college-contact", []string{"alumni", "disband-group", "--alumni-dept-id", "12345"}}, + {"college-contact", []string{"alumni", "get-alumni-org-from-graduate"}}, + {"college-contact", []string{"alumni", "create-alumni-org", "--org-name", "计算机校友会"}}, + {"college-contact", []string{"alumni", "add-alumni-org-main-admins", "--admin-user-ids", "u1,u2"}}, + // graduate (16) + {"college-contact", []string{"graduate", "query-graduate-years"}}, + {"college-contact", []string{"graduate", "query-graduate-depts", "--dept-id", "12345"}}, + {"college-contact", []string{"graduate", "query-graduate-sub-depts", "--dept-id", "12345"}}, + {"college-contact", []string{"graduate", "query-page-graduate-users", "--dept-id", "12345"}}, + {"college-contact", []string{"graduate", "get-task-result", "--request-no", "req001"}}, + {"college-contact", []string{"graduate", "get-alumni-org"}}, + {"college-contact", []string{"graduate", "query-restore-sub-depts", "--dept-id", "12345"}}, + {"college-contact", []string{"graduate", "query-dept-deleted-emps", "--dept-id", "12345"}}, + {"college-contact", []string{"graduate", "search-graduate", "--keyword", "张三"}}, + {"college-contact", []string{"graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"}}, + {"college-contact", []string{"graduate", "all-graduate", "--graduate-year", "2026"}}, + {"college-contact", []string{"graduate", "batch-graduate", "--dept-id", "12345", "--staff-ids", "s1,s2"}}, + {"college-contact", []string{"graduate", "delete-and-graduate", "--dept-id", "12345", "--staff-ids", "s1,s2"}}, + {"college-contact", []string{"graduate", "batch-delete-pending", "--dept-id", "12345", "--staff-ids", "s1,s2"}}, + {"college-contact", []string{"graduate", "batch-update-pending", "--dept-id", "12345", "--staff-ids", "s1,s2", "--graduate-year", "2026"}}, + {"college-contact", []string{"graduate", "commit-restore", "--graduate-dept-ids", "1,2"}}, + // group (9) + {"college-contact", []string{"group", "query-group-rule"}}, + {"college-contact", []string{"group", "get-group-rule-schedule"}}, + {"college-contact", []string{"group", "query-preview-data"}}, + {"college-contact", []string{"group", "create-group-rule", "--name", "自动分组", "--tag-code", "TAG1", "--dept-type", "COLLEGE"}}, + {"college-contact", []string{"group", "delete-group-rule", "--rule-id", "1"}}, + {"college-contact", []string{"group", "enable-group-rule", "--rule-id", "1"}}, + {"college-contact", []string{"group", "disable-group-rule", "--rule-id", "1"}}, + {"college-contact", []string{"group", "set-group-rule-schedule"}}, + {"college-contact", []string{"group", "execute-group-rule"}}, + } + + // 按产品分组的命令构建器 + builders := map[string]func() *cobra.Command{ + "edu-contact": newEduContactCommand, + "edu-group": newEduGroupCommand, + "edu-app": newEduAppCommand, + "edu-familygroup": newEduFamilyGroupCommand, + "college-contact": newCollegeContactCommand, + } + + // 安装 dry-run caller + caller := &recruitCaptureCaller{dryRun: true} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + + passed := 0 + failed := 0 + currentProduct := "" + + for i, c := range allCmds { + if c.product != currentProduct { + currentProduct = c.product + fmt.Printf("\n══════════════════════════════════════════════════════\n") + fmt.Printf(" %s\n", strings.ToUpper(currentProduct)) + fmt.Printf("══════════════════════════════════════════════════════\n") + } + + buildFn, ok := builders[c.product] + if !ok { + t.Fatalf("unknown product: %s", c.product) + } + root := buildFn() + root.SetArgs(c.args) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + + // Reset caller capture + caller.productID = "" + caller.tool = "" + caller.args = nil + + err := root.Execute() + cmdPath := fmt.Sprintf("dws %s %s", c.product, strings.Join(c.args, " ")) + + if err != nil { + failed++ + fmt.Printf(" [%3d] ✗ FAIL: %s\n", i+1, cmdPath) + fmt.Printf(" Error: %v\n", err) + t.Errorf("command %d failed: %s → %v", i+1, cmdPath, err) + } else { + passed++ + fmt.Printf(" [%3d] ✓ %s\n", i+1, cmdPath) + if caller.tool != "" { + fmt.Printf(" → MCP: %s.%s\n", caller.productID, caller.tool) + } + } + } + + fmt.Printf("\n══════════════════════════════════════════════════════\n") + fmt.Printf(" SUMMARY: %d passed, %d failed, %d total\n", passed, failed, passed+failed) + fmt.Printf("══════════════════════════════════════════════════════\n") +} From 38360969e7a54892f109399578d04d652d0255d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=8D=B7?= Date: Mon, 24 Aug 2026 13:36:31 +0800 Subject: [PATCH 07/33] style: gofmt college_contact files --- internal/helpers/college_contact.go | 12 ------------ internal/helpers/college_contact_test.go | 1 - 2 files changed, 13 deletions(-) diff --git a/internal/helpers/college_contact.go b/internal/helpers/college_contact.go index ce0321ab..3b025f89 100644 --- a/internal/helpers/college_contact.go +++ b/internal/helpers/college_contact.go @@ -1985,7 +1985,6 @@ func newCollegeContactCommand() *cobra.Command { } input["staffIds"] = staffIDs - // 可选 int64 for _, pair := range []struct { flag string @@ -3696,7 +3695,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) } - return callMCPToolOnServer("college-contact", "delete_alumnus", map[string]any{ "input": map[string]any{ "staffId": staffId, @@ -3783,7 +3781,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--staff-ids 不能为空列表") } - return callMCPToolOnServer("college-contact", "delete_alumni_invite_record", map[string]any{ "input": map[string]any{ "alumniDeptId": alumniDeptId, @@ -3915,7 +3912,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--alumni-dept-id 须为整数: %w", err) } - return callMCPToolOnServer("college-contact", "disband_alumni_group", map[string]any{ "input": map[string]any{ "alumniDeptId": alumniDeptId, @@ -4796,7 +4792,6 @@ func newCollegeContactCommand() *cobra.Command { input["requestNo"] = strings.TrimSpace(v) } - return callMCPToolOnServer("college-contact", "commit_graduate", map[string]any{ "input": input, }) @@ -4874,7 +4869,6 @@ func newCollegeContactCommand() *cobra.Command { input["requestNo"] = strings.TrimSpace(v) } - return callMCPToolOnServer("college-contact", "all_graduate", map[string]any{ "input": input, }) @@ -4959,7 +4953,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--staff-ids 不能为空列表") } - return callMCPToolOnServer("college-contact", "batch_graduate", map[string]any{ "input": map[string]any{ "deptId": deptId, @@ -5047,7 +5040,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--staff-ids 不能为空列表") } - return callMCPToolOnServer("college-contact", "delete_and_graduate", map[string]any{ "input": map[string]any{ "deptId": deptId, @@ -5135,7 +5127,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--staff-ids 不能为空列表") } - return callMCPToolOnServer("college-contact", "batch_delete_pending", map[string]any{ "input": map[string]any{ "deptId": deptId, @@ -5234,7 +5225,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--graduate-year 须为整数: %w", err) } - return callMCPToolOnServer("college-contact", "batch_update_pending", map[string]any{ "input": map[string]any{ "deptId": deptId, @@ -5327,7 +5317,6 @@ func newCollegeContactCommand() *cobra.Command { input["requestNo"] = strings.TrimSpace(v) } - return callMCPToolOnServer("college-contact", "commit_restore", map[string]any{ "input": input, }) @@ -5677,7 +5666,6 @@ func newCollegeContactCommand() *cobra.Command { return fmt.Errorf("--rule-id 须为整数: %w", err) } - return callMCPToolOnServer("college-contact", "delete_group_rule", map[string]any{ "input": map[string]any{"ruleId": ruleID}, }) diff --git a/internal/helpers/college_contact_test.go b/internal/helpers/college_contact_test.go index 3201aa3f..244ac9f2 100644 --- a/internal/helpers/college_contact_test.go +++ b/internal/helpers/college_contact_test.go @@ -147,7 +147,6 @@ func TestCrossPlatformCoverageCollegeContactDestructive_ProceedsWithYes(t *testi } } - // withCollegeContactCaller installs a dry-run capture caller so happy-path // command execution exercises each RunE up to the callMCPToolOnServer dispatch // without requiring a live MCP transport. In dry-run mode destructive From 583d31141dfc7da74953c495125a67b0e1685523 Mon Sep 17 00:00:00 2001 From: xiatian Date: Mon, 24 Aug 2026 11:12:59 +0800 Subject: [PATCH 08/33] feat(sheet): support local files for float images --- .changes/sheet-float-image-local-file.md | 5 + ...ma_sheet_float_image_file_contract_test.go | 73 ++ internal/cli/schema_constraints_runtime.go | 8 +- .../cli/schema_parameter_mapping_ledger.go | 2 + internal/helpers/sheet_float_image.go | 71 +- internal/helpers/sheet_float_image_file.go | 333 +++++++++ .../sheet_float_image_file_fifo_test.go | 20 + .../helpers/sheet_float_image_file_test.go | 695 ++++++++++++++++++ skills/mono/references/products/sheet.md | 6 +- .../products/sheet/sheet-media-image.md | 39 +- .../multi/dingtalk-misc/references/sheet.md | 6 +- .../references/sheet/sheet-media-image.md | 39 +- 12 files changed, 1254 insertions(+), 43 deletions(-) create mode 100644 .changes/sheet-float-image-local-file.md create mode 100644 internal/app/schema_sheet_float_image_file_contract_test.go create mode 100644 internal/helpers/sheet_float_image_file.go create mode 100644 internal/helpers/sheet_float_image_file_fifo_test.go create mode 100644 internal/helpers/sheet_float_image_file_test.go diff --git a/.changes/sheet-float-image-local-file.md b/.changes/sheet-float-image-local-file.md new file mode 100644 index 00000000..cf00618c --- /dev/null +++ b/.changes/sheet-float-image-local-file.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow. diff --git a/internal/app/schema_sheet_float_image_file_contract_test.go b/internal/app/schema_sheet_float_image_file_contract_test.go new file mode 100644 index 00000000..46c65ded --- /dev/null +++ b/internal/app/schema_sheet_float_image_file_contract_test.go @@ -0,0 +1,73 @@ +package app + +import ( + "testing" +) + +func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) { + payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(), + "sheet.create_float_image", + "sheet.update_float_image", + ) + + create := payload.Tools["sheet.create_float_image"] + if create == nil { + t.Fatal("missing sheet.create_float_image") + } + if create["interface_mode"] != "mcp" { + t.Fatalf("create interface mode = %#v", create["interface_mode"]) + } + createRef, _ := create["interface_ref"].(map[string]any) + if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" { + t.Fatalf("create interface ref = %#v", createRef) + } + createDryRun, _ := create["dry_run"].(map[string]any) + if createDryRun["preview_kind"] != "request" { + t.Fatalf("create dry-run = %#v", createDryRun) + } + if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false { + t.Fatalf("create dry-run remote_reads = %#v", remoteReads) + } + createParameters, _ := create["parameters"].(map[string]any) + file, _ := createParameters["file"].(map[string]any) + src, _ := createParameters["src"].(map[string]any) + if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" { + t.Fatalf("create --file metadata = %#v", file) + } + if schemaContractString(file["property"]) != "" { + t.Fatalf("create --file leaked an RPC property: %#v", file["property"]) + } + if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" { + t.Fatalf("create --src compatibility metadata = %#v", src) + } + assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"}) + createConstraints, _ := create["constraints"].(map[string]any) + if groups, ok := createConstraints["require_one_of"].([]any); ok && len(groups) != 0 { + t.Fatalf("create unexpectedly publishes require_one_of: %#v", groups) + } + + update := payload.Tools["sheet.update_float_image"] + if update == nil { + t.Fatal("missing sheet.update_float_image") + } + updateDryRun, _ := update["dry_run"].(map[string]any) + if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" { + t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun) + } + updateParameters, _ := update["parameters"].(map[string]any) + updateFile, _ := updateParameters["file"].(map[string]any) + if schemaContractString(updateFile["property"]) != "" { + t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"]) + } + assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"}) + assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"}) + + root := NewRootCommand() + for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} { + command := exactCommandForTest(root, cliPath) + if command == nil || command.Flags().Lookup("file") == nil { + t.Fatalf("%s has no executable --file flag", cliPath) + } + } + +} diff --git a/internal/cli/schema_constraints_runtime.go b/internal/cli/schema_constraints_runtime.go index 423528d6..46302dcc 100644 --- a/internal/cli/schema_constraints_runtime.go +++ b/internal/cli/schema_constraints_runtime.go @@ -96,7 +96,13 @@ func init() { registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style") registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size") registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria") - registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y") + RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"file", "src"}}, + }) + RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{ + MutuallyExclusive: [][]string{{"file", "src"}}, + RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}}, + }) registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color") registerRequireOneOf("sheet.import", "folder-token", "workspace") registerRequireOneOf("wiki.search_wikiSpaces", "query", "type") diff --git a/internal/cli/schema_parameter_mapping_ledger.go b/internal/cli/schema_parameter_mapping_ledger.go index b2468ff3..1cc9c432 100644 --- a/internal/cli/schema_parameter_mapping_ledger.go +++ b/internal/cli/schema_parameter_mapping_ledger.go @@ -554,6 +554,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.", "sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties", + "sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image", "sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.", "sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.", @@ -646,6 +647,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{ "sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.", "sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", "sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties", + "sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image", "sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.", "sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.", "sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.", diff --git a/internal/helpers/sheet_float_image.go b/internal/helpers/sheet_float_image.go index 99cf85f5..1a15b774 100644 --- a/internal/helpers/sheet_float_image.go +++ b/internal/helpers/sheet_float_image.go @@ -15,18 +15,21 @@ func newFloatImageCmds() []*cobra.Command { 浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。 -使用流程: - 1. 先通过 media-upload 上传本地图片获取 resourceUrl - 2. 再通过 create-float-image 将图片以浮动方式放置到工作表上 +直接传 --file 可在一个命令中上传本地图片并创建浮动图片。 +已经通过 media-upload 获得 resourceUrl 时,也可以改传 --src;--file 与 --src 二选一。 --range 指定浮动图片锚定的单元格位置,使用 A1 表示法(如 A1、B3)。 --width / --height 为必填,单位像素,必须为正整数。 --offset-x / --offset-y 可选,表示相对锚点单元格左上角的偏移量(像素),默认 0。`, - Example: ` # 先上传图片获取 resourceUrl + Example: ` # 直接从本地文件创建 + dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \ + --file ./chart.png --range A1 --width 400 --height 300 + + # 高级用法:先上传图片获取 resourceUrl dws sheet media-upload --node NODE_ID --file ./chart.png # 输出: resourceUrl: /core/api/resources/img/xxxx... - # 再创建浮动图片(--src 传入 media-upload 返回的 resourceUrl) + # 再通过 --src 创建浮动图片 dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \ --src "/core/api/resources/img/xxxx..." --range A1 --width 400 --height 300 @@ -34,7 +37,11 @@ func newFloatImageCmds() []*cobra.Command { dws sheet create-float-image --node NODE_ID --sheet-id SHEET_ID \ --src "/core/api/resources/img/xxxx..." --range B2 --width 200 --height 150 --offset-x 10 --offset-y 20`, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "node", "sheet-id", "src", "range"); err != nil { + if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil { + return err + } + filePath, src, err := validateFloatImageCreateInput(cmd) + if err != nil { return err } width, err := cmd.Flags().GetInt("width") @@ -54,7 +61,6 @@ func newFloatImageCmds() []*cobra.Command { toolArgs := map[string]any{ "nodeId": mustGetFlag(cmd, "node"), "sheetId": mustGetFlag(cmd, "sheet-id"), - "src": mustGetFlag(cmd, "src"), "range": mustGetFlag(cmd, "range"), "width": width, "height": height, @@ -73,7 +79,11 @@ func newFloatImageCmds() []*cobra.Command { } toolArgs["offsetY"] = oy } - return callMCPTool("create_float_image", toolArgs) + if filePath != "" { + return runFloatImageFileMode(cmd, "create_float_image", filePath, toolArgs) + } + toolArgs["src"] = src + return callMCPToolContext(cmd.Context(), "create_float_image", toolArgs) }, } DeclareLeafMetadata(createFloatImageCmd, LeafSpec{ @@ -89,26 +99,30 @@ func newFloatImageCmds() []*cobra.Command { CLIPath: "sheet create-float-image", PrimaryCLIPath: "sheet create-float-image", }, - Description: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。", + Description: "从本地文件或已上传的 resourceUrl 创建浮动图片。", + DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false}, Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "create_float_image"}, }, Selection: contract.SelectionSpec{ - AgentSummary: "创建浮动图片(src 必须来自 media-upload 的 resourceUrl)。", + AgentSummary: "从本地文件或已上传的 resourceUrl 创建浮动图片。", UseWhen: []string{"需要在单元格上方悬浮图片、不占用单元格内容时"}, AvoidWhen: []string{"单元格内嵌图片用 write-image;更新/删除浮动图用 update/delete-float-image"}, Examples: []string{"dws sheet create-float-image --node --sheet-id --src \"/core/api/resources/img/...\" --range A1 --width 400 --height 300"}, }, Parameters: []contract.ParamDecl{ {Name: "node", Property: "nodeId"}, + {Name: "file", Required: boolPtr(false), RequiredWhen: "exactly one of --file or --src must be provided"}, + {Name: "src", Property: "src", Required: boolPtr(false)}, }, }, }) createFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") createFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)") - createFloatImageCmd.Flags().String("src", "", "图片资源路径,通过 media-upload 获取的 resourceUrl (必填)") + createFloatImageCmd.Flags().String("file", "", "本地图片文件路径,与 --src 二选一") + createFloatImageCmd.Flags().String("src", "", "通过 media-upload 获取的 resourceUrl,与 --file 二选一") createFloatImageCmd.Flags().String("range", "", "锚点单元格,A1 表示法,如 A1、B3 (必填)") createFloatImageCmd.Flags().Int("width", 0, "图片宽度,像素 (必填)") createFloatImageCmd.Flags().Int("height", 0, "图片高度,像素 (必填)") @@ -217,8 +231,8 @@ floatImageId 可通过 list-float-images 获取。`, Short: "更新浮动图片属性", Long: `更新钉钉表格指定工作表中浮动图片的属性。 -可更新的属性包括:图片资源路径(src)、锚点位置、尺寸、偏移量。 -至少需要传入一个更新字段(--src / --range / --width / --height / --offset-x / --offset-y)。 +可直接传 --file 上传本地图片并替换浮动图片,也可传已上传图片的 --src。 +至少需要传入一个更新字段(--file / --src / --range / --width / --height / --offset-x / --offset-y),且 --file 与 --src 不能同时使用。 floatImageId 可通过 list-float-images 获取。`, Example: ` # 移动浮动图片到新位置 dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --range C5 @@ -226,10 +240,15 @@ floatImageId 可通过 list-float-images 获取。`, # 调整尺寸 dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID --width 600 --height 400 - # 替换图片(需先 media-upload 新图片获取 resourceUrl) + # 直接用本地文件替换图片 + dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \ + --file ./replacement.png + + # 高级用法:用 media-upload 返回的 resourceUrl 替换图片 dws sheet update-float-image --node NODE_ID --sheet-id SHEET_ID --float-image-id FI_ID \ --src "/core/api/resources/img/xxxx..."`, RunE: func(cmd *cobra.Command, args []string) error { + fileChanged := cmd.Flags().Changed("file") srcChanged := cmd.Flags().Changed("src") rangeChanged := cmd.Flags().Changed("range") widthChanged := cmd.Flags().Changed("width") @@ -237,8 +256,17 @@ floatImageId 可通过 list-float-images 获取。`, oxChanged := cmd.Flags().Changed("offset-x") oyChanged := cmd.Flags().Changed("offset-y") - if !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged { - return fmt.Errorf("--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个") + if !fileChanged && !srcChanged && !rangeChanged && !widthChanged && !heightChanged && !oxChanged && !oyChanged { + return fmt.Errorf("%s", floatImageUpdateFieldsError) + } + filePath, src, err := validateFloatImageUpdateInput(cmd) + if err != nil { + return err + } + if fileChanged { + if err := validateRequiredFlags(cmd, "node", "sheet-id", "float-image-id"); err != nil { + return err + } } toolArgs := map[string]any{ @@ -247,7 +275,7 @@ floatImageId 可通过 list-float-images 获取。`, "floatImageId": mustGetFlag(cmd, "float-image-id"), } if srcChanged { - toolArgs["src"], _ = cmd.Flags().GetString("src") + toolArgs["src"] = src } if rangeChanged { toolArgs["range"], _ = cmd.Flags().GetString("range") @@ -280,7 +308,10 @@ floatImageId 可通过 list-float-images 获取。`, } toolArgs["offsetY"] = oy } - return callMCPTool("update_float_image", toolArgs) + if fileChanged { + return runFloatImageFileMode(cmd, "update_float_image", filePath, toolArgs) + } + return callMCPToolContext(cmd.Context(), "update_float_image", toolArgs) }, } DeclareLeafMetadata(updateFloatImageCmd, LeafSpec{ @@ -297,6 +328,7 @@ floatImageId 可通过 list-float-images 获取。`, PrimaryCLIPath: "sheet update-float-image", }, Description: "更新浮动图片锚点、尺寸、偏移或资源路径。", + DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false}, Interface: &contract.InterfaceSpec{ Mode: "mcp", Availability: "available", @@ -310,12 +342,15 @@ floatImageId 可通过 list-float-images 获取。`, }, Parameters: []contract.ParamDecl{ {Name: "node", Property: "nodeId"}, + {Name: "file", Required: boolPtr(false)}, + {Name: "src", Property: "src", Required: boolPtr(false)}, }, }, }) updateFloatImageCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") updateFloatImageCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)") updateFloatImageCmd.Flags().String("float-image-id", "", "浮动图片 ID (必填)") + updateFloatImageCmd.Flags().String("file", "", "用于替换浮动图片的本地图片路径,与 --src 不能同时使用") updateFloatImageCmd.Flags().String("src", "", "新的图片资源路径,通过 media-upload 获取的 resourceUrl") updateFloatImageCmd.Flags().String("range", "", "新的锚点单元格,A1 表示法") updateFloatImageCmd.Flags().Int("width", 0, "新的图片宽度,像素") diff --git a/internal/helpers/sheet_float_image_file.go b/internal/helpers/sheet_float_image_file.go new file mode 100644 index 00000000..6e9ba0d9 --- /dev/null +++ b/internal/helpers/sheet_float_image_file.go @@ -0,0 +1,333 @@ +package helpers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +const ( + floatImageCreateInputError = "--file 与 --src 必须且只能提供一个" + floatImageUpdateInputError = "--file 与 --src 不能同时提供" + floatImageUpdateFieldsError = "--file、--src、--range、--width、--height、--offset-x、--offset-y 至少必须提供一个" + floatImagePlannedResource = "" + floatImageUploadTimeout = 5 * time.Minute +) + +type floatImageLocalFile struct { + file *os.File + path string + name string + mimeType string + size int64 +} + +type floatImageUploadInfo struct { + uploadURL string + resourceID string + resourceURL string +} + +func validateFloatImageCreateInput(cmd *cobra.Command) (string, string, error) { + filePath, _ := cmd.Flags().GetString("file") + src, _ := cmd.Flags().GetString("src") + if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" { + return "", "", fmt.Errorf("--file 不能为空") + } + hasFile := strings.TrimSpace(filePath) != "" + hasSrc := strings.TrimSpace(src) != "" + if hasFile == hasSrc { + return "", "", fmt.Errorf("%s", floatImageCreateInputError) + } + return filePath, src, nil +} + +func validateFloatImageUpdateInput(cmd *cobra.Command) (string, string, error) { + filePath, _ := cmd.Flags().GetString("file") + src, _ := cmd.Flags().GetString("src") + if cmd.Flags().Changed("file") && cmd.Flags().Changed("src") { + return "", "", fmt.Errorf("%s", floatImageUpdateInputError) + } + if cmd.Flags().Changed("file") && strings.TrimSpace(filePath) == "" { + return "", "", fmt.Errorf("--file 不能为空") + } + return filePath, src, nil +} + +func runFloatImageFileMode(cmd *cobra.Command, toolName, filePath string, toolArgs map[string]any) error { + local, err := openFloatImageLocalFile(filePath) + if err != nil { + return err + } + defer local.file.Close() + + if deps.Caller.DryRun() { + return printFloatImageFileDryRun(toolName, toolArgs, local) + } + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + + jsonMode := strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") + if !jsonMode { + deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取浮动图片上传凭证 (%s, %d bytes)...", local.name, local.size)) + } + uploadInfo, err := resolveFloatImageUploadInfo(ctx, toolArgs["nodeId"], local) + if err != nil { + return err + } + if !jsonMode { + deps.Out.PrintKeyValue("resourceId", uploadInfo.resourceID) + deps.Out.PrintKeyValue("resourceUrl", uploadInfo.resourceURL) + deps.Out.PrintInfo("[2/3] 上传本地图片...") + } + client := newFloatImageUploadClient() + if err := putFloatImageFileWithClient(ctx, client, uploadInfo.uploadURL, local); err != nil { + return err + } + + if !jsonMode { + deps.Out.PrintInfo("[3/3] 提交浮动图片变更...") + } + toolArgs["src"] = uploadInfo.resourceURL + if err := callMCPToolContext(ctx, toolName, toolArgs); err != nil { + return fmt.Errorf("图片已上传(resourceId=%s,resourceUrl=%s),但浮动图片操作失败;已有重试可能造成重复创建或更新,请先用 list/get/UI 核验后再重试: %w", uploadInfo.resourceID, uploadInfo.resourceURL, err) + } + return nil +} + +func openFloatImageLocalFile(path string) (*floatImageLocalFile, error) { + return openFloatImageLocalFileWith(path, os.Stat, os.Open) +} + +func openFloatImageLocalFileWith(path string, stat func(string) (os.FileInfo, error), open func(string) (*os.File, error)) (*floatImageLocalFile, error) { + info, err := stat(path) + if err != nil { + return nil, fmt.Errorf("无法读取 --file %q: %w", path, err) + } + if err := validateFloatImageFileInfo(info); err != nil { + return nil, err + } + file, err := open(path) + if err != nil { + return nil, fmt.Errorf("无法打开 --file %q: %w", path, err) + } + openedInfo, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, fmt.Errorf("无法读取已打开的 --file %q: %w", path, err) + } + if err := validateFloatImageFileInfo(openedInfo); err != nil { + _ = file.Close() + return nil, err + } + return &floatImageLocalFile{ + file: file, + path: path, + name: filepath.Base(path), + mimeType: inferMimeType(filepath.Base(path)), + size: openedInfo.Size(), + }, nil +} + +func validateFloatImageFileInfo(info os.FileInfo) error { + if !info.Mode().IsRegular() { + return fmt.Errorf("--file 必须是普通文件") + } + if info.Size() <= 0 { + return fmt.Errorf("--file 不能为空文件") + } + return nil +} + +func printFloatImageFileDryRun(toolName string, toolArgs map[string]any, local *floatImageLocalFile) error { + arguments := make(map[string]any, len(toolArgs)+1) + for key, value := range toolArgs { + arguments[key] = value + } + arguments["src"] = floatImagePlannedResource + stages := []string{"validate_local_file", "get_upload_credentials", "upload_file", toolName} + if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") { + return deps.Out.PrintJSON(map[string]any{ + "dry_run": true, + "executed": false, + "tool": toolName, + "arguments": arguments, + "local_file": map[string]any{ + "path": local.path, "name": local.name, "mime_type": local.mimeType, "size": local.size, + }, + "stages": stages, + }) + } + deps.Out.PrintKeyValue("操作", "上传本地图片并提交浮动图片变更") + deps.Out.PrintKeyValue("文件", local.path) + deps.Out.PrintKeyValue("名称", local.name) + deps.Out.PrintKeyValue("类型", local.mimeType) + deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", local.size)) + deps.Out.PrintKeyValue("Tool", toolName) + deps.Out.PrintKeyValue("阶段", strings.Join(stages, " -> ")) + return nil +} + +func resolveFloatImageUploadInfo(ctx context.Context, nodeID any, local *floatImageLocalFile) (floatImageUploadInfo, error) { + result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{ + "nodeId": nodeID, + "fileName": local.name, + "fileSize": float64(local.size), + "mimeType": local.mimeType, + }) + if err != nil { + return floatImageUploadInfo{}, WrapError(err) + } + return parseFloatImageUploadInfo(result) +} + +func parseFloatImageUploadInfo(result *edition.ToolResult) (floatImageUploadInfo, error) { + if result == nil { + return floatImageUploadInfo{}, invalidFloatImageCredentialError() + } + text := "" + for _, content := range result.Content { + if content.Type == "text" && strings.TrimSpace(content.Text) != "" { + text = content.Text + break + } + } + if text == "" { + return floatImageUploadInfo{}, invalidFloatImageCredentialError() + } + type credential struct { + UploadURL string `json:"uploadUrl"` + ResourceID string `json:"resourceId"` + ResourceURL string `json:"resourceUrl"` + } + var envelope struct { + credential + Result *credential `json:"result"` + } + if err := json.Unmarshal([]byte(text), &envelope); err != nil { + return floatImageUploadInfo{}, invalidFloatImageCredentialError() + } + parsed := envelope.credential + if envelope.Result != nil { + parsed = *envelope.Result + } + parsed.UploadURL = strings.TrimSpace(parsed.UploadURL) + parsed.ResourceID = strings.TrimSpace(parsed.ResourceID) + parsed.ResourceURL = strings.TrimSpace(parsed.ResourceURL) + if parsed.UploadURL == "" || !validFloatImageResourceID(parsed.ResourceID) || !validFloatImageResourceURL(parsed.ResourceURL) { + return floatImageUploadInfo{}, invalidFloatImageCredentialError() + } + if err := validateFloatImageUploadURL(parsed.UploadURL); err != nil { + return floatImageUploadInfo{}, err + } + return floatImageUploadInfo{uploadURL: parsed.UploadURL, resourceID: parsed.ResourceID, resourceURL: parsed.ResourceURL}, nil +} + +func invalidFloatImageCredentialError() error { + return &CLIError{ + Code: CodeMCPToolError, + Message: "获取浮动图片上传凭证失败:响应格式无效", + Suggestion: "请重试;若持续失败,请检查 doc/get_doc_attachment_upload_info 服务", + Operation: "doc/get_doc_attachment_upload_info", + } +} + +func validFloatImageResourceID(value string) bool { + return value != "" && len(value) <= 4096 && strings.IndexFunc(value, unicode.IsControl) < 0 +} + +func validFloatImageResourceURL(value string) bool { + if value == "" || len(value) > 8192 || strings.IndexFunc(value, unicode.IsControl) >= 0 || !strings.HasPrefix(value, "/") || strings.HasPrefix(value, "//") { + return false + } + parsed, err := url.Parse(value) + return err == nil && parsed.Host == "" && parsed.RawQuery == "" && parsed.Fragment == "" +} + +// Keep this policy aligned with internal/transport.Client.isEndpointTrusted. +// It stays Sheet-private in V1 so local-file float-image support does not +// refactor the shared transport boundary selected by unrelated products. +func validateFloatImageUploadURL(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil || !parsed.IsAbs() || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { + return invalidFloatImageUploadURLError() + } + if strings.EqualFold(parsed.Scheme, "https") { + return nil + } + if !strings.EqualFold(parsed.Scheme, "http") || os.Getenv("DWS_ALLOW_HTTP_ENDPOINTS") != "1" || !isFloatImageLoopbackHost(parsed.Hostname()) { + return invalidFloatImageUploadURLError() + } + return nil +} + +func isFloatImageLoopbackHost(host string) bool { + return host == "localhost" || host == "127.0.0.1" || host == "::1" +} + +func invalidFloatImageUploadURLError() error { + return &CLIError{ + Code: CodeInvalidParam, + Message: "浮动图片上传地址无效或不受信任", + Suggestion: "上传地址必须是 HTTPS;本地开发仅允许显式开启的 loopback HTTP", + Operation: "sheet/float-image-upload", + } +} + +func newFloatImageUploadClient() *http.Client { + return &http.Client{ + Timeout: floatImageUploadTimeout, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// This intentionally does not reuse doc.defaultHTTPPutFile or localio.PutFile: +// both have incompatible retry/status/host behavior, and the Doc helper can +// copy a signed URL or response body into errors. Keep this fixed-message PUT +// until a shared secure-upload contract can replace all upload implementations. +func putFloatImageFileWithClient(ctx context.Context, client *http.Client, rawURL string, local *floatImageLocalFile) error { + if client == nil { + return fmt.Errorf("浮动图片文件上传失败:HTTP 客户端不可用") + } + if _, err := local.file.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("浮动图片文件上传失败:无法读取本地文件") + } + request, err := http.NewRequestWithContext(ctx, http.MethodPut, rawURL, local.file) + if err != nil { + return fmt.Errorf("浮动图片文件上传失败:无法创建请求") + } + request.ContentLength = local.size + request.Header.Set("Content-Type", local.mimeType) + response, err := client.Do(request) + if err != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return fmt.Errorf("浮动图片文件上传已取消: %w", ctxErr) + } + if errors.Is(err, context.DeadlineExceeded) || os.IsTimeout(err) { + return fmt.Errorf("浮动图片文件上传失败:已超过传输时限") + } + return fmt.Errorf("浮动图片文件上传失败:网络请求失败") + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 64<<10)) + if response.StatusCode != http.StatusOK { + return fmt.Errorf("浮动图片文件上传失败:HTTP %d", response.StatusCode) + } + return nil +} diff --git a/internal/helpers/sheet_float_image_file_fifo_test.go b/internal/helpers/sheet_float_image_file_fifo_test.go new file mode 100644 index 00000000..a8fec8ed --- /dev/null +++ b/internal/helpers/sheet_float_image_file_fifo_test.go @@ -0,0 +1,20 @@ +//go:build darwin || linux || freebsd || openbsd || netbsd || dragonfly + +package helpers + +import ( + "path/filepath" + "strings" + "syscall" + "testing" +) + +func TestCrossPlatformCoverageFloatImageFIFORejectedBeforeOpen(t *testing.T) { + path := filepath.Join(t.TempDir(), "image.fifo") + if err := syscall.Mkfifo(path, 0o600); err != nil { + t.Fatalf("mkfifo: %v", err) + } + if _, err := openFloatImageLocalFile(path); err == nil || !strings.Contains(err.Error(), "普通文件") { + t.Fatalf("FIFO error = %v", err) + } +} diff --git a/internal/helpers/sheet_float_image_file_test.go b/internal/helpers/sheet_float_image_file_test.go new file mode 100644 index 00000000..12ad5a7e --- /dev/null +++ b/internal/helpers/sheet_float_image_file_test.go @@ -0,0 +1,695 @@ +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type floatImageToolCall struct { + product string + tool string + args map[string]any +} + +type floatImageTestCaller struct { + dryRun bool + format string + calls []floatImageToolCall + call func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) +} + +func (c *floatImageTestCaller) CallTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) { + copyArgs := make(map[string]any, len(args)) + for key, value := range args { + copyArgs[key] = value + } + index := len(c.calls) + c.calls = append(c.calls, floatImageToolCall{product: product, tool: tool, args: copyArgs}) + if c.call != nil { + return c.call(ctx, product, tool, args, index) + } + return floatImageTextResult(`{"floatImage":{"id":"fi-1"}}`), nil +} + +func (c *floatImageTestCaller) Format() string { + if c.format == "" { + return "json" + } + return c.format +} + +func (c *floatImageTestCaller) DryRun() bool { return c.dryRun } +func (*floatImageTestCaller) Fields() string { return "" } +func (*floatImageTestCaller) JQ() string { return "" } + +type floatImageRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn floatImageRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { + return fn(request) +} + +func floatImageTextResult(text string) *edition.ToolResult { + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}} +} + +func floatImageCredentialResult(uploadURL string, nested bool) *edition.ToolResult { + credential := map[string]any{ + "uploadUrl": uploadURL, "resourceId": "rid-1", "resourceUrl": "/core/api/resources/img/rid-1", + } + payload := any(credential) + if nested { + payload = map[string]any{"result": credential} + } + raw, _ := json.Marshal(payload) + return floatImageTextResult(string(raw)) +} + +func floatImageCommandForTest(t *testing.T, name string) *cobra.Command { + t.Helper() + for _, command := range newFloatImageCmds() { + if command.Name() == name { + return command + } + } + t.Fatalf("missing float-image command %s", name) + return nil +} + +func setFloatImageFlags(t *testing.T, command *cobra.Command, values map[string]string) { + t.Helper() + for name, value := range values { + if err := command.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } +} + +func installFloatImageDeps(t *testing.T, caller *floatImageTestCaller, output io.Writer) { + t.Helper() + if output == nil { + output = io.Discard + } + testseam.Swap(t, &deps, &Deps{Caller: caller, Out: NewFormatterWithWriters(output, io.Discard)}) + previousArgs := os.Args + os.Args = []string{"dws", "sheet"} + t.Cleanup(func() { os.Args = previousArgs }) +} + +func writeFloatImageFixture(t *testing.T, name, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + return path +} + +func openFloatImageFixture(t *testing.T, path string) *floatImageLocalFile { + t.Helper() + local, err := openFloatImageLocalFile(path) + if err != nil { + t.Fatalf("open fixture: %v", err) + } + t.Cleanup(func() { _ = local.file.Close() }) + return local +} + +func TestCrossPlatformCoverageFloatImageInputValidation(t *testing.T) { + create := floatImageCommandForTest(t, "create-float-image") + if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) { + t.Fatalf("missing create input error = %v", err) + } + setFloatImageFlags(t, create, map[string]string{"file": "a.png", "src": "/img/a"}) + if _, _, err := validateFloatImageCreateInput(create); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) { + t.Fatalf("conflicting create input error = %v", err) + } + createEmptyFile := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, createEmptyFile, map[string]string{"file": " ", "src": "/img/a"}) + if _, _, err := validateFloatImageCreateInput(createEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") { + t.Fatalf("empty create file error = %v", err) + } + + fileOnly := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, fileOnly, map[string]string{"file": " a.png "}) + if file, src, err := validateFloatImageCreateInput(fileOnly); err != nil || file != " a.png " || src != "" { + t.Fatalf("file-only input = %q/%q/%v", file, src, err) + } + srcOnly := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, srcOnly, map[string]string{"src": " /img/a "}) + if file, src, err := validateFloatImageCreateInput(srcOnly); err != nil || file != "" || src != " /img/a " { + t.Fatalf("src-only input = %q/%q/%v", file, src, err) + } + + updateBoth := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, updateBoth, map[string]string{"file": "a.png", "src": "/img/a"}) + if _, _, err := validateFloatImageUpdateInput(updateBoth); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) { + t.Fatalf("conflicting update input error = %v", err) + } + updateEmptyFile := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, updateEmptyFile, map[string]string{"file": " "}) + if _, _, err := validateFloatImageUpdateInput(updateEmptyFile); err == nil || !strings.Contains(err.Error(), "--file 不能为空") { + t.Fatalf("empty update file error = %v", err) + } + updateSrc := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, updateSrc, map[string]string{"src": ""}) + if file, src, err := validateFloatImageUpdateInput(updateSrc); err != nil || file != "" || src != "" { + t.Fatalf("src update compatibility = %q/%q/%v", file, src, err) + } +} + +func TestCrossPlatformCoverageFloatImageLocalFileValidation(t *testing.T) { + validPath := writeFloatImageFixture(t, "chart.png", "png-data") + local, err := openFloatImageLocalFile(validPath) + if err != nil { + t.Fatal(err) + } + if local.name != "chart.png" || local.mimeType != "image/png" || local.size != int64(len("png-data")) { + t.Fatalf("local metadata = %#v", local) + } + _ = local.file.Close() + + if _, err := openFloatImageLocalFile(filepath.Join(t.TempDir(), "missing.png")); err == nil || !strings.Contains(err.Error(), "无法读取") { + t.Fatalf("missing file error = %v", err) + } + if _, err := openFloatImageLocalFile(t.TempDir()); err == nil || !strings.Contains(err.Error(), "普通文件") { + t.Fatalf("directory error = %v", err) + } + emptyPath := writeFloatImageFixture(t, "empty.png", "") + if _, err := openFloatImageLocalFile(emptyPath); err == nil || !strings.Contains(err.Error(), "不能为空文件") { + t.Fatalf("empty error = %v", err) + } + + validInfo, err := os.Stat(validPath) + if err != nil { + t.Fatal(err) + } + statValid := func(string) (os.FileInfo, error) { return validInfo, nil } + openFailure := errors.New("open sentinel") + if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return nil, openFailure }); !errors.Is(err, openFailure) { + t.Fatalf("open error = %v", err) + } + closed, err := os.Open(validPath) + if err != nil { + t.Fatal(err) + } + _ = closed.Close() + if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return closed, nil }); err == nil || !strings.Contains(err.Error(), "已打开") { + t.Fatalf("opened stat error = %v", err) + } + if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(t.TempDir()) }); err == nil || !strings.Contains(err.Error(), "普通文件") { + t.Fatalf("descriptor type error = %v", err) + } + if _, err := openFloatImageLocalFileWith(validPath, statValid, func(string) (*os.File, error) { return os.Open(emptyPath) }); err == nil || !strings.Contains(err.Error(), "不能为空文件") { + t.Fatalf("descriptor size error = %v", err) + } +} + +func TestCrossPlatformCoverageFloatImageCredentialParsingAndURLPolicy(t *testing.T) { + invalidResults := []*edition.ToolResult{ + nil, + {}, + {Content: []edition.ContentBlock{{Type: "image", Text: "credential-secret"}}}, + floatImageTextResult("{"), + floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid"}`), + floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"bad\nvalue","resourceUrl":"/core/api/resources/img/rid"}`), + floatImageTextResult(`{"uploadUrl":"https://upload.invalid/signed-secret","resourceId":"rid","resourceUrl":"https://resource.invalid/rid"}`), + floatImageTextResult(`{"uploadUrl":"http://example.com/signed-secret","resourceId":"rid","resourceUrl":"/core/api/resources/img/rid"}`), + } + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "") + for index, result := range invalidResults { + _, err := parseFloatImageUploadInfo(result) + if err == nil { + t.Fatalf("invalid result %d succeeded", index) + } + if strings.Contains(err.Error(), "signed-secret") || strings.Contains(err.Error(), "credential-secret") { + t.Fatalf("invalid result %d leaked secret: %v", index, err) + } + } + + top := floatImageTextResult(`{"uploadUrl":"https://upload.invalid/path?signature=secret","resourceId":"rid-top","resourceUrl":"/core/api/resources/img/top"}`) + info, err := parseFloatImageUploadInfo(top) + if err != nil || info.resourceID != "rid-top" || info.resourceURL != "/core/api/resources/img/top" { + t.Fatalf("top-level credential = %#v, %v", info, err) + } + nested := &edition.ToolResult{Content: []edition.ContentBlock{ + {Type: "text", Text: " "}, + {Type: "image", Text: "ignored"}, + {Type: "text", Text: `{"result":{"uploadUrl":"https://upload.invalid/nested","resourceId":"rid-nested","resourceUrl":"/core/api/resources/img/nested"}}`}, + }} + info, err = parseFloatImageUploadInfo(nested) + if err != nil || info.resourceID != "rid-nested" { + t.Fatalf("nested credential = %#v, %v", info, err) + } + + for _, value := range []string{"", "//host/path", "/path?secret=x", "/path#fragment", "/bad\npath"} { + if validFloatImageResourceURL(value) { + t.Errorf("resource URL %q unexpectedly valid", value) + } + } + if !validFloatImageResourceURL("/core/api/resources/img/ok") || validFloatImageResourceID("") || validFloatImageResourceID("bad\x1bvalue") { + t.Fatal("resource identifier validation mismatch") + } + + for _, rawURL := range []string{"not a URL", "/relative", "https://user:pass@example.com/path", "https://example.com/path#fragment", "ftp://example.com/path", "http://example.com/path"} { + if err := validateFloatImageUploadURL(rawURL); err == nil { + t.Errorf("upload URL %q unexpectedly valid", rawURL) + } + } + if err := validateFloatImageUploadURL("https://example.com/path?signature=secret"); err != nil { + t.Fatalf("HTTPS URL rejected: %v", err) + } + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1") + for _, rawURL := range []string{"http://localhost/path", "http://127.0.0.1/path", "http://[::1]/path"} { + if err := validateFloatImageUploadURL(rawURL); err != nil { + t.Errorf("loopback URL %q rejected: %v", rawURL, err) + } + } + if err := validateFloatImageUploadURL("http://192.0.2.1/path"); err == nil || isFloatImageLoopbackHost("not-an-ip") || isFloatImageLoopbackHost("127.0.0.2") { + t.Fatalf("non-loopback policy mismatch: %v", err) + } +} + +func TestCrossPlatformCoverageFloatImageHTTPPut(t *testing.T) { + path := writeFloatImageFixture(t, "chart.png", "streamed-image") + + t.Run("nil client", func(t *testing.T) { + local := openFloatImageFixture(t, path) + if err := putFloatImageFileWithClient(context.Background(), nil, "https://upload.invalid", local); err == nil { + t.Fatal("nil client succeeded") + } + }) + t.Run("closed file", func(t *testing.T) { + local := openFloatImageFixture(t, path) + _ = local.file.Close() + if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, "https://upload.invalid", local); err == nil || !strings.Contains(err.Error(), "无法读取本地文件") { + t.Fatalf("closed file error = %v", err) + } + }) + t.Run("invalid request", func(t *testing.T) { + local := openFloatImageFixture(t, path) + if err := putFloatImageFileWithClient(context.Background(), http.DefaultClient, ":", local); err == nil || !strings.Contains(err.Error(), "无法创建请求") { + t.Fatalf("request error = %v", err) + } + }) + t.Run("transport secret redacted", func(t *testing.T) { + local := openFloatImageFixture(t, path) + client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("transport signed-secret https://upload.invalid") + })} + err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/signed-secret", local) + if err == nil || strings.Contains(err.Error(), "signed-secret") || !strings.Contains(err.Error(), "网络请求失败") { + t.Fatalf("transport error = %v", err) + } + }) + t.Run("cancel", func(t *testing.T) { + local := openFloatImageFixture(t, path) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + client := &http.Client{Transport: floatImageRoundTripFunc(func(request *http.Request) (*http.Response, error) { + return nil, request.Context().Err() + })} + err := putFloatImageFileWithClient(ctx, client, "https://upload.invalid/path", local) + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancel error = %v", err) + } + }) + t.Run("client deadline", func(t *testing.T) { + local := openFloatImageFixture(t, path) + client := &http.Client{Transport: floatImageRoundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, context.DeadlineExceeded + })} + err := putFloatImageFileWithClient(context.Background(), client, "https://upload.invalid/path", local) + if err == nil || !strings.Contains(err.Error(), "超过传输时限") { + t.Fatalf("deadline error = %v", err) + } + }) + t.Run("non-200 body redacted", func(t *testing.T) { + local := openFloatImageFixture(t, path) + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(response, "response-body-secret") + })) + defer server.Close() + err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL+"/signed-secret", local) + if err == nil || !strings.Contains(err.Error(), "HTTP 403") || strings.Contains(err.Error(), "response-body-secret") || strings.Contains(err.Error(), "signed-secret") { + t.Fatalf("status error = %v", err) + } + }) + t.Run("stream success", func(t *testing.T) { + local := openFloatImageFixture(t, path) + var method, contentType, body string + var contentLength int64 + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + method = request.Method + contentType = request.Header.Get("Content-Type") + contentLength = request.ContentLength + raw, _ := io.ReadAll(request.Body) + body = string(raw) + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + if err := putFloatImageFileWithClient(context.Background(), server.Client(), server.URL, local); err != nil { + t.Fatal(err) + } + if method != http.MethodPut || contentType != "image/png" || contentLength != int64(len("streamed-image")) || body != "streamed-image" { + t.Fatalf("request = %s %s %d %q", method, contentType, contentLength, body) + } + }) + t.Run("redirect not followed", func(t *testing.T) { + local := openFloatImageFixture(t, path) + destinationCalls := 0 + destination := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { destinationCalls++ })) + defer destination.Close() + source := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.Header().Set("Location", destination.URL+"/location-secret") + response.WriteHeader(http.StatusTemporaryRedirect) + })) + defer source.Close() + client := newFloatImageUploadClient() + if client.Timeout != floatImageUploadTimeout { + t.Fatalf("timeout = %v", client.Timeout) + } + err := putFloatImageFileWithClient(context.Background(), client, source.URL, local) + if err == nil || !strings.Contains(err.Error(), "HTTP 307") || strings.Contains(err.Error(), "location-secret") || destinationCalls != 0 { + t.Fatalf("redirect error/calls = %v/%d", err, destinationCalls) + } + }) +} + +func TestCrossPlatformCoverageFloatImageDryRun(t *testing.T) { + path := writeFloatImageFixture(t, "chart.png", "image") + for _, format := range []string{"json", "table"} { + t.Run(format, func(t *testing.T) { + caller := &floatImageTestCaller{dryRun: true, format: format} + var output bytes.Buffer + installFloatImageDeps(t, caller, &output) + command := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{ + "node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80", + }) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run remote calls = %d", len(caller.calls)) + } + if format == "json" { + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("decode dry-run: %v; output=%s", err, output.String()) + } + arguments, _ := payload["arguments"].(map[string]any) + if payload["tool"] != "create_float_image" || arguments["src"] != floatImagePlannedResource || payload["dry_run"] != true || payload["executed"] != false { + t.Fatalf("dry-run payload = %#v", payload) + } + if _, exists := payload["preview_kind"]; exists { + t.Fatalf("request preview unexpectedly declares preview_kind: %#v", payload) + } + } else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> create_float_image") { + t.Fatalf("pretty dry-run = %s", output.String()) + } + }) + } + for _, format := range []string{"json", "table"} { + t.Run("update-"+format, func(t *testing.T) { + caller := &floatImageTestCaller{dryRun: true, format: format} + var output bytes.Buffer + installFloatImageDeps(t, caller, &output) + command := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, command, map[string]string{ + "node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "file": path, "width": "120", + }) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 0 { + t.Fatalf("update dry-run remote calls = %d", len(caller.calls)) + } + if format == "json" { + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatalf("decode update dry-run: %v; output=%s", err, output.String()) + } + arguments, _ := payload["arguments"].(map[string]any) + if payload["tool"] != "update_float_image" || arguments["src"] != floatImagePlannedResource || arguments["floatImageId"] != "fi-1" || payload["dry_run"] != true || payload["executed"] != false { + t.Fatalf("update dry-run payload = %#v", payload) + } + } else if !strings.Contains(output.String(), "validate_local_file -> get_upload_credentials -> upload_file -> update_float_image") { + t.Fatalf("update pretty dry-run = %s", output.String()) + } + }) + } +} + +func TestCrossPlatformCoverageFloatImageCommandWorkflows(t *testing.T) { + path := writeFloatImageFixture(t, "chart.png", "command-image") + t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1") + + t.Run("create src", func(t *testing.T) { + caller := &floatImageTestCaller{} + installFloatImageDeps(t, caller, io.Discard) + command := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{ + "node": "node-1", "sheet-id": "sheet-1", "src": "/core/api/resources/img/existing", "range": "A1", "width": "100", "height": "80", + }) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].product != "sheet" || caller.calls[0].tool != "create_float_image" || caller.calls[0].args["src"] != "/core/api/resources/img/existing" { + t.Fatalf("src calls = %#v", caller.calls) + } + }) + + t.Run("create and update file", func(t *testing.T) { + var uploadBodies []string + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + raw, _ := io.ReadAll(request.Body) + uploadBodies = append(uploadBodies, string(raw)) + response.WriteHeader(http.StatusOK) + })) + defer server.Close() + for _, commandName := range []string{"create-float-image", "update-float-image"} { + t.Run(commandName, func(t *testing.T) { + caller := &floatImageTestCaller{call: func(_ context.Context, product, tool string, _ map[string]any, index int) (*edition.ToolResult, error) { + if index == 0 { + if product != "doc" || tool != "get_doc_attachment_upload_info" { + t.Fatalf("credential route = %s/%s", product, tool) + } + return floatImageCredentialResult(server.URL+"/signed-secret", commandName == "update-float-image"), nil + } + return floatImageTextResult(`{"floatImage":{"id":"fi-ok"}}`), nil + }} + var output bytes.Buffer + installFloatImageDeps(t, caller, &output) + command := floatImageCommandForTest(t, commandName) + flags := map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path} + if commandName == "create-float-image" { + flags["range"], flags["width"], flags["height"] = "A1", "100", "80" + } else { + flags["float-image-id"], flags["width"] = "fi-1", "120" + } + setFloatImageFlags(t, command, flags) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || caller.calls[1].product != "sheet" || caller.calls[1].tool != strings.ReplaceAll(commandName, "-", "_") { + t.Fatalf("workflow calls = %#v", caller.calls) + } + if caller.calls[0].args["fileName"] != "chart.png" || caller.calls[0].args["mimeType"] != "image/png" || caller.calls[1].args["src"] != "/core/api/resources/img/rid-1" { + t.Fatalf("workflow args = %#v", caller.calls) + } + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil || payload["floatImage"] == nil { + t.Fatalf("final output = %s, %v", output.String(), err) + } + }) + } + if len(uploadBodies) != 2 || uploadBodies[0] != "command-image" || uploadBodies[1] != "command-image" { + t.Fatalf("upload bodies = %#v", uploadBodies) + } + }) + + t.Run("pretty progress", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) })) + defer server.Close() + caller := &floatImageTestCaller{format: "table", call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) { + if index == 0 { + return floatImageCredentialResult(server.URL, false), nil + } + return floatImageTextResult(`{"floatImage":{"id":"fi-pretty"}}`), nil + }} + var output bytes.Buffer + installFloatImageDeps(t, caller, &output) + command := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"}) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + for _, marker := range []string{"[1/3]", "resourceId", "[2/3]", "[3/3]"} { + if !strings.Contains(output.String(), marker) { + t.Fatalf("pretty output missing %q: %s", marker, output.String()) + } + } + }) + + t.Run("validation and local error", func(t *testing.T) { + caller := &floatImageTestCaller{} + installFloatImageDeps(t, caller, io.Discard) + create := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, create, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "range": "A1", "width": "100", "height": "80"}) + if err := create.RunE(create, nil); err == nil || !strings.Contains(err.Error(), floatImageCreateInputError) { + t.Fatalf("create validation = %v", err) + } + missing := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, missing, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": filepath.Join(t.TempDir(), "missing.png"), "range": "A1", "width": "100", "height": "80"}) + if err := missing.RunE(missing, nil); err == nil || !strings.Contains(err.Error(), "无法读取") { + t.Fatalf("local error = %v", err) + } + update := floatImageCommandForTest(t, "update-float-image") + if err := update.RunE(update, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateFieldsError) { + t.Fatalf("update fields error = %v", err) + } + conflict := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, conflict, map[string]string{"file": path, "src": "/img/a"}) + if err := conflict.RunE(conflict, nil); err == nil || !strings.Contains(err.Error(), floatImageUpdateInputError) { + t.Fatalf("update conflict = %v", err) + } + missingIdentity := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, missingIdentity, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path}) + if err := missingIdentity.RunE(missingIdentity, nil); err == nil || !strings.Contains(err.Error(), "float-image-id") || len(caller.calls) != 0 { + t.Fatalf("update identity error/calls = %v/%d", err, len(caller.calls)) + } + }) + + t.Run("credential and put failures", func(t *testing.T) { + credentialCause := errors.New("credential transport") + caller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) { + return nil, credentialCause + }} + installFloatImageDeps(t, caller, io.Discard) + command := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"}) + if err := command.RunE(command, nil); !errors.Is(err, credentialCause) { + t.Fatalf("credential error = %v", err) + } + + secret := "credential-response-secret" + malformed := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) { + return floatImageTextResult(secret), nil + }} + installFloatImageDeps(t, malformed, io.Discard) + command = floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"}) + if err := command.RunE(command, nil); err == nil || strings.Contains(err.Error(), secret) { + t.Fatalf("malformed credential error = %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { + response.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(response, "put-body-secret") + })) + defer server.Close() + putCaller := &floatImageTestCaller{call: func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) { + return floatImageCredentialResult(server.URL+"/signed-secret", false), nil + }} + installFloatImageDeps(t, putCaller, io.Discard) + command = floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"}) + if err := command.RunE(command, nil); err == nil || len(putCaller.calls) != 1 || strings.Contains(err.Error(), "put-body-secret") || strings.Contains(err.Error(), "signed-secret") { + t.Fatalf("put error/calls = %v/%d", err, len(putCaller.calls)) + } + }) + + t.Run("partial final failure", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.WriteHeader(http.StatusOK) })) + defer server.Close() + finalCause := errors.New("final operation sentinel") + caller := &floatImageTestCaller{call: func(_ context.Context, _, _ string, _ map[string]any, index int) (*edition.ToolResult, error) { + if index == 0 { + return floatImageCredentialResult(server.URL+"/signed-secret", false), nil + } + return nil, finalCause + }} + installFloatImageDeps(t, caller, io.Discard) + command := floatImageCommandForTest(t, "create-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "file": path, "range": "A1", "width": "100", "height": "80"}) + err := command.RunE(command, nil) + if !errors.Is(err, finalCause) || !strings.Contains(err.Error(), "resourceId=rid-1") || !strings.Contains(err.Error(), "list/get/UI") || strings.Contains(err.Error(), "signed-secret") { + t.Fatalf("partial failure = %v", err) + } + }) + + t.Run("update move only", func(t *testing.T) { + caller := &floatImageTestCaller{} + installFloatImageDeps(t, caller, io.Discard) + command := floatImageCommandForTest(t, "update-float-image") + setFloatImageFlags(t, command, map[string]string{"node": "node-1", "sheet-id": "sheet-1", "float-image-id": "fi-1", "range": "C5"}) + if err := command.RunE(command, nil); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].args["range"] != "C5" { + t.Fatalf("move-only calls = %#v", caller.calls) + } + }) +} + +func TestCrossPlatformCoverageResolveFloatImageUploadInfo(t *testing.T) { + path := writeFloatImageFixture(t, "chart.webp", "webp") + local := openFloatImageFixture(t, path) + caller := &floatImageTestCaller{call: func(ctx context.Context, product, tool string, args map[string]any, _ int) (*edition.ToolResult, error) { + if ctx == nil || product != "doc" || tool != "get_doc_attachment_upload_info" { + t.Fatalf("route/context = %s/%s/%v", product, tool, ctx) + } + if args["nodeId"] != "node-1" || args["fileName"] != "chart.webp" || args["mimeType"] != "image/webp" || args["fileSize"] != float64(4) { + t.Fatalf("credential args = %#v", args) + } + return floatImageCredentialResult("https://upload.invalid/path", false), nil + }} + installFloatImageDeps(t, caller, io.Discard) + info, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local) + if err != nil || info.resourceID != "rid-1" { + t.Fatalf("resolve info = %#v/%v", info, err) + } + + cause := fmt.Errorf("timeout synthetic") + caller.call = func(context.Context, string, string, map[string]any, int) (*edition.ToolResult, error) { + return nil, cause + } + if _, err := resolveFloatImageUploadInfo(context.Background(), "node-1", local); !errors.Is(err, cause) { + t.Fatalf("resolve error = %v", err) + } + + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + caller.call = func(ctx context.Context, _ string, _ string, _ map[string]any, _ int) (*edition.ToolResult, error) { + if !errors.Is(ctx.Err(), context.Canceled) { + t.Fatalf("credential context error = %v", ctx.Err()) + } + return nil, ctx.Err() + } + if _, err := resolveFloatImageUploadInfo(cancelled, "node-1", local); !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled credential error = %v", err) + } +} + +func TestCrossPlatformCoverageFloatImageUploadDeadlineDocumented(t *testing.T) { + client := newFloatImageUploadClient() + if client.Timeout != 5*time.Minute || client.CheckRedirect == nil { + t.Fatalf("upload client = %#v", client) + } +} diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md index 980aa3e7..e24b749f 100644 --- a/skills/mono/references/products/sheet.md +++ b/skills/mono/references/products/sheet.md @@ -51,7 +51,7 @@ | 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 | | 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 | | 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 | -| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | +| 图片写入单元格 / 浮动图片 | `write-image` / `create-float-image --file` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | | 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 | | 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 | | 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 | @@ -80,7 +80,7 @@ | [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` | | [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` | | [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` | -| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` | +| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片优先用 `create-float-image --file`,已有 resourceUrl 时用 `--src`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` | | [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` | | [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` | | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` | @@ -237,7 +237,7 @@ Flags: | "搜索公式文本" | `dws sheet find --node --sheet-id --query "<公式片段>" --match-formula` | | "正则搜索 / 不区分大小写" | `dws sheet find --node --sheet-id --query "" --use-regexp --match-case=false` | | "插入图片到单元格" | `dws sheet write-image --node --sheet-id --range A1 --file <图片路径>` | -| "创建浮动图片" | 先 `dws sheet media-upload --node --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node --sheet-id --src "" --range A1 --width <宽> --height <高>` | +| "创建浮动图片" | `dws sheet create-float-image --node --sheet-id --file <图片路径> --range A1 --width <宽> --height <高>` | ## URL 与 ID 前置 diff --git a/skills/mono/references/products/sheet/sheet-media-image.md b/skills/mono/references/products/sheet/sheet-media-image.md index d3e236f0..aeee5e65 100644 --- a/skills/mono/references/products/sheet/sheet-media-image.md +++ b/skills/mono/references/products/sheet/sheet-media-image.md @@ -17,7 +17,7 @@ ### 浮动图片 用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图": -- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image` +- 创建浮动图片 → `create-float-image --file <本地图片>`;已有 `resourceUrl` 时可改用 `--src` - 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同 用户说"查看浮动图片/有哪些浮动图片/浮动图片列表": @@ -71,7 +71,11 @@ Flags: Usage: dws sheet create-float-image [flags] Example: - # 先上传图片获取 resourceUrl + # 直接上传本地图片并创建浮动图片 + dws sheet create-float-image --node --sheet-id \ + --file ./chart.png --range A1 --width 400 --height 300 + + # 高级用法:先上传图片获取 resourceUrl dws sheet media-upload --node --file ./chart.png # 输出: resourceUrl: /core/api/resources/img/xxxx... @@ -85,7 +89,8 @@ Example: Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) - --src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填) + --file string 本地图片文件路径,与 --src 二选一 + --src string 图片资源路径,通过 media-upload 获取的 resourceUrl,与 --file 二选一 --range string 锚点单元格,A1 表示法,如 A1、B3 (必填) --width int 图片宽度,像素,正整数 (必填) --height int 图片高度,像素,正整数 (必填) @@ -94,7 +99,8 @@ Flags: ``` 浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。 -- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL +- `--file` 与 `--src` 必须且只能提供一个;`--file` 会在命令内完成凭证获取、文件上传和浮动图片创建 +- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL;需要自定义上传名称/MIME 时使用这个高级两步流程 - `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) - `--width` / `--height` 为必填,单位像素,必须为正整数 - `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数 @@ -138,13 +144,18 @@ Example: # 调整尺寸 dws sheet update-float-image --node --sheet-id --float-image-id --width 600 --height 400 - # 替换图片(需先 media-upload 新图片获取 resourceUrl) + # 直接用本地图片替换 + dws sheet update-float-image --node --sheet-id --float-image-id \ + --file ./replacement.png + + # 高级用法:通过已上传的 resourceUrl 替换 dws sheet update-float-image --node --sheet-id --float-image-id \ --src "/core/api/resources/img/xxxx..." Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) --float-image-id string 浮动图片 ID (必填) + --file string 用于替换浮动图片的本地图片路径,与 --src 不能同时使用 --src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl --range string 新的锚点单元格,A1 表示法 --width int 新的图片宽度,像素 @@ -153,7 +164,7 @@ Flags: --offset-y int 新的垂直偏移量,像素 ``` -更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。 +更新浮动图片的属性,`--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个;`--file` 与 `--src` 不能同时使用。 `--float-image-id` 可通过 `list-float-images` 获取。 ### 删除浮动图片 @@ -210,6 +221,16 @@ dws sheet range update --node --sheet-id --range "A1:B1" \ dws sheet range update --node --sheet-id --range "A2:A2" \ --values '[[{"type":"text","text":"MacBook Pro"}]]' -f json dws sheet write-image --node --sheet-id --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json + +# ── 工作流 11: 创建或替换浮动图片 ── + +# 从本地图片直接创建 +dws sheet create-float-image --node --sheet-id \ + --file ./chart.png --range A1 --width 400 --height 300 -f json + +# 从本地图片直接替换已有浮动图 +dws sheet update-float-image --node --sheet-id \ + --float-image-id --file ./replacement.png -f json ``` ## 上下文传递 @@ -232,11 +253,11 @@ dws sheet write-image --node --sheet-id --range B2:B2 --fil - `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤 - ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象 - `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 -- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL +- `create-float-image --file` 可直接输入本地图片;仅在需要 `--name` / `--mime-type` 覆盖或复用既有资源时,先用 `media-upload` 获取 `resourceUrl` 再传 `--src` - `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) - `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数 - `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小 -- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl` -- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个 +- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;优先直接传 `--file` +- `update-float-image` 的 `--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个,且 `--file` 与 `--src` 不能同时使用 - `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete) - `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除 diff --git a/skills/multi/dingtalk-misc/references/sheet.md b/skills/multi/dingtalk-misc/references/sheet.md index 0ea12239..56789b02 100644 --- a/skills/multi/dingtalk-misc/references/sheet.md +++ b/skills/multi/dingtalk-misc/references/sheet.md @@ -51,7 +51,7 @@ | 清空 / 排序 / 填充 / 复制移动区域 | `range clear` / `range sort` / `range fill` / `range copy-to` / `range move-to` | [sheet-range-operations](sheet/sheet-range-operations.md) | 用读写组合模拟服务端原子操作 | | 合并、冻结、分组、行高列宽 | `sheet info` + 结构命令 | [sheet-workbook](sheet/sheet-workbook.md)、[sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 从 `range read` / CSV 空值推断结构 | | 多个原子写操作组合 | `batch-update` | [sheet-batch-operations](sheet/sheet-batch-operations.md) | 多次独立调用导致半成品 | -| 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | +| 图片写入单元格 / 浮动图片 | `write-image` / `create-float-image --file` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | | 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 | | 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 | | 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 | @@ -80,7 +80,7 @@ | [sheet-dimension-operations](sheet/sheet-dimension-operations.md) | 行列增删移动、属性设置与分组。当用户说"插入行/列"、"删除行/列"、"隐藏/显示行列"、"设行高/列宽"、"移动行/列"、"追加空行/空列"、"创建/取消行列分组"、"新建分组并设为折叠/展开"时使用。命令:`insert-dimension`/`delete-dimension`/`update-dimension`/`move-dimension`/`add-dimension`/`group-dimension`/`ungroup-dimension` | | [sheet-style-format](sheet/sheet-style-format.md) | 单元格样式与合并。当用户说"设样式"、"改颜色/字体/对齐"、"数字格式(百分比/货币/日期)"、"合并/取消合并"时使用。纯样式/批量样式走 `set-style`;写值同时设置少量 cell 样式可用 `range update` 的 `cellStyles`。命令:`range set-style`/`range batch-set-style`/`merge-cells`/`unmerge-cells` | | [sheet-dropdown](sheet/sheet-dropdown.md) | 下拉列表管理。当用户说"设置下拉"、"下拉选项"、"删除下拉"时使用。命令:`set-dropdown`/`get-dropdown`/`delete-dropdown` | -| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片需先 `media-upload` 再 `create-float-image`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` | +| [sheet-media-image](sheet/sheet-media-image.md) | 附件上传与图片。当用户说"上传附件"、"写入图片到单元格"、"浮动图片"时使用。单元格图片用 `write-image`(禁止 `range update`);浮动图片优先用 `create-float-image --file`,已有 resourceUrl 时用 `--src`。命令:`media-upload`/`write-image`/`create-float-image`/`get-float-image`/`list-float-images`/`update-float-image`/`delete-float-image` | | [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` | | [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` | | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` | @@ -237,7 +237,7 @@ Flags: | "搜索公式文本" | `dws sheet find --node --sheet-id --query "<公式片段>" --match-formula` | | "正则搜索 / 不区分大小写" | `dws sheet find --node --sheet-id --query "" --use-regexp --match-case=false` | | "插入图片到单元格" | `dws sheet write-image --node --sheet-id --range A1 --file <图片路径>` | -| "创建浮动图片" | 先 `dws sheet media-upload --node --file <图片路径>` 获取 `resourceUrl`,再 `dws sheet create-float-image --node --sheet-id --src "" --range A1 --width <宽> --height <高>` | +| "创建浮动图片" | `dws sheet create-float-image --node --sheet-id --file <图片路径> --range A1 --width <宽> --height <高>` | ## URL 与 ID 前置 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md b/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md index d3e236f0..aeee5e65 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md @@ -17,7 +17,7 @@ ### 浮动图片 用户说"浮动图片/悬浮图片/在表格上放一张图/加个浮动的图": -- 创建浮动图片 → 先 `media-upload` 上传图片获取 `resourceUrl`,再 `create-float-image` +- 创建浮动图片 → `create-float-image --file <本地图片>`;已有 `resourceUrl` 时可改用 `--src` - 浮动图片悬浮于单元格之上,不占用单元格内容,与 `write-image`(写入单元格内部的图片)不同 用户说"查看浮动图片/有哪些浮动图片/浮动图片列表": @@ -71,7 +71,11 @@ Flags: Usage: dws sheet create-float-image [flags] Example: - # 先上传图片获取 resourceUrl + # 直接上传本地图片并创建浮动图片 + dws sheet create-float-image --node --sheet-id \ + --file ./chart.png --range A1 --width 400 --height 300 + + # 高级用法:先上传图片获取 resourceUrl dws sheet media-upload --node --file ./chart.png # 输出: resourceUrl: /core/api/resources/img/xxxx... @@ -85,7 +89,8 @@ Example: Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) - --src string 图片资源路径,通过 media-upload 获取的 resourceUrl (必填) + --file string 本地图片文件路径,与 --src 二选一 + --src string 图片资源路径,通过 media-upload 获取的 resourceUrl,与 --file 二选一 --range string 锚点单元格,A1 表示法,如 A1、B3 (必填) --width int 图片宽度,像素,正整数 (必填) --height int 图片高度,像素,正整数 (必填) @@ -94,7 +99,8 @@ Flags: ``` 浮动图片悬浮于单元格之上,不占用单元格内容,可自由定位和调整大小。 -- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL +- `--file` 与 `--src` 必须且只能提供一个;`--file` 会在命令内完成凭证获取、文件上传和浮动图片创建 +- `--src` 必须是 `media-upload` 返回的 `resourceUrl`(格式为 `/core/api/resources/img/...`),不能直接传外部 URL;需要自定义上传名称/MIME 时使用这个高级两步流程 - `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) - `--width` / `--height` 为必填,单位像素,必须为正整数 - `--offset-x` / `--offset-y` 表示相对锚点单元格左上角的偏移量(像素),默认 0,不能为负数 @@ -138,13 +144,18 @@ Example: # 调整尺寸 dws sheet update-float-image --node --sheet-id --float-image-id --width 600 --height 400 - # 替换图片(需先 media-upload 新图片获取 resourceUrl) + # 直接用本地图片替换 + dws sheet update-float-image --node --sheet-id --float-image-id \ + --file ./replacement.png + + # 高级用法:通过已上传的 resourceUrl 替换 dws sheet update-float-image --node --sheet-id --float-image-id \ --src "/core/api/resources/img/xxxx..." Flags: --node string 表格文档 ID 或 URL (必填) --sheet-id string 工作表 ID 或名称 (必填) --float-image-id string 浮动图片 ID (必填) + --file string 用于替换浮动图片的本地图片路径,与 --src 不能同时使用 --src string 新的图片资源路径,通过 media-upload 获取的 resourceUrl --range string 新的锚点单元格,A1 表示法 --width int 新的图片宽度,像素 @@ -153,7 +164,7 @@ Flags: --offset-y int 新的垂直偏移量,像素 ``` -更新浮动图片的属性,`--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个。 +更新浮动图片的属性,`--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少传入一个;`--file` 与 `--src` 不能同时使用。 `--float-image-id` 可通过 `list-float-images` 获取。 ### 删除浮动图片 @@ -210,6 +221,16 @@ dws sheet range update --node --sheet-id --range "A1:B1" \ dws sheet range update --node --sheet-id --range "A2:A2" \ --values '[[{"type":"text","text":"MacBook Pro"}]]' -f json dws sheet write-image --node --sheet-id --range B2:B2 --file ./macbook.png --width 150 --height 100 -f json + +# ── 工作流 11: 创建或替换浮动图片 ── + +# 从本地图片直接创建 +dws sheet create-float-image --node --sheet-id \ + --file ./chart.png --range A1 --width 400 --height 300 -f json + +# 从本地图片直接替换已有浮动图 +dws sheet update-float-image --node --sheet-id \ + --float-image-id --file ./replacement.png -f json ``` ## 上下文传递 @@ -232,11 +253,11 @@ dws sheet write-image --node --sheet-id --range B2:B2 --fil - `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤 - ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象 - `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 -- `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL +- `create-float-image --file` 可直接输入本地图片;仅在需要 `--name` / `--mime-type` 覆盖或复用既有资源时,先用 `media-upload` 获取 `resourceUrl` 再传 `--src` - `create-float-image` 的 `--range` 使用 A1 表示法指定锚点单元格(如 `A1`、`B3`),支持带工作表前缀(如 `Sheet1!A1`) - `create-float-image` 的 `--width` / `--height` 为必填,单位像素,必须为正整数;`--offset-x` / `--offset-y` 可选,默认 0,不能为负数 - `write-image`(单元格内嵌图片)vs `create-float-image`(浮动图片):`write-image` 将图片写入单元格内部,占据单元格内容;`create-float-image` 创建悬浮于单元格之上的浮动图片,不占用单元格内容,可自由调整位置和大小 -- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;`--src` 必须来自 `media-upload` 的 `resourceUrl` -- `update-float-image` 的 `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个 +- ★ **浮动图片用 `create-float-image` 不用 `write-image`**:两者用途不同——`write-image` 写入单元格内部,`create-float-image` 创建悬浮于单元格之上的浮动图片;优先直接传 `--file` +- `update-float-image` 的 `--file` / `--src` / `--range` / `--width` / `--height` / `--offset-x` / `--offset-y` 至少必须提供一个,且 `--file` 与 `--src` 不能同时使用 - `list-float-images` 返回 `floatImages` 数组和 `totalCount`,每个元素包含 `id`(用于后续 get / update / delete) - `delete-float-image` 操作不可恢复,删除后图片将从工作表中移除 From fdb5fc19f537018ddc6fd56b753e820fbc60bf59 Mon Sep 17 00:00:00 2001 From: xiatian Date: Mon, 24 Aug 2026 14:17:20 +0800 Subject: [PATCH 09/33] test(sheet): cover float image redirect rejection --- internal/helpers/sheet_float_image_file_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/helpers/sheet_float_image_file_test.go b/internal/helpers/sheet_float_image_file_test.go index 12ad5a7e..95282895 100644 --- a/internal/helpers/sheet_float_image_file_test.go +++ b/internal/helpers/sheet_float_image_file_test.go @@ -377,7 +377,7 @@ func TestCrossPlatformCoverageFloatImageHTTPPut(t *testing.T) { defer destination.Close() source := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, _ *http.Request) { response.Header().Set("Location", destination.URL+"/location-secret") - response.WriteHeader(http.StatusTemporaryRedirect) + response.WriteHeader(http.StatusFound) })) defer source.Close() client := newFloatImageUploadClient() @@ -385,7 +385,7 @@ func TestCrossPlatformCoverageFloatImageHTTPPut(t *testing.T) { t.Fatalf("timeout = %v", client.Timeout) } err := putFloatImageFileWithClient(context.Background(), client, source.URL, local) - if err == nil || !strings.Contains(err.Error(), "HTTP 307") || strings.Contains(err.Error(), "location-secret") || destinationCalls != 0 { + if err == nil || !strings.Contains(err.Error(), "HTTP 302") || strings.Contains(err.Error(), "location-secret") || destinationCalls != 0 { t.Fatalf("redirect error/calls = %v/%d", err, destinationCalls) } }) From 2dc52afd60541dba539a3ad224847c7bb840ce62 Mon Sep 17 00:00:00 2001 From: xiatian Date: Mon, 24 Aug 2026 16:36:12 +0800 Subject: [PATCH 10/33] fix(sheet): align float image schema constraints --- internal/app/schema_sheet_float_image_file_contract_test.go | 5 +---- internal/cli/schema_constraints_runtime.go | 1 + 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/app/schema_sheet_float_image_file_contract_test.go b/internal/app/schema_sheet_float_image_file_contract_test.go index 46c65ded..67fe086f 100644 --- a/internal/app/schema_sheet_float_image_file_contract_test.go +++ b/internal/app/schema_sheet_float_image_file_contract_test.go @@ -41,10 +41,7 @@ func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) { t.Fatalf("create --src compatibility metadata = %#v", src) } assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"}) - createConstraints, _ := create["constraints"].(map[string]any) - if groups, ok := createConstraints["require_one_of"].([]any); ok && len(groups) != 0 { - t.Fatalf("create unexpectedly publishes require_one_of: %#v", groups) - } + assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"}) update := payload.Tools["sheet.update_float_image"] if update == nil { diff --git a/internal/cli/schema_constraints_runtime.go b/internal/cli/schema_constraints_runtime.go index 46302dcc..f5dae2ca 100644 --- a/internal/cli/schema_constraints_runtime.go +++ b/internal/cli/schema_constraints_runtime.go @@ -98,6 +98,7 @@ func init() { registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria") RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{ MutuallyExclusive: [][]string{{"file", "src"}}, + RequireOneOf: [][]string{{"file", "src"}}, }) RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{ MutuallyExclusive: [][]string{{"file", "src"}}, From 2e60b8e121c2f208421aa93a1d12113c3821ff1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=8D=B7?= Date: Mon, 24 Aug 2026 17:01:57 +0800 Subject: [PATCH 11/33] test: pair confirm-gate and exact-dispatch assertions for all destructive edu leaves Auto-CR flagged that the destructive-command tests only checked the error string, so a regression that let a command slip past the confirmation gate (or dispatch the wrong payload) would go unnoticed. Every user_required leaf across college-contact (15), edu-contact (2), edu-group (1) and edu-app (3) is now verified in pairs with a fresh non-dry-run capture caller: without explicit confirmation the gate must return confirmation_required AND the caller must see zero calls; with --yes the command must produce exactly one call carrying the correct productID, tool name and complete argument payload. --- internal/helpers/college_contact_test.go | 191 +++++++++++++++++++---- internal/helpers/edu_app_test.go | 103 ++++++++++++ internal/helpers/edu_contact_test.go | 89 +++++++++++ internal/helpers/edu_group_test.go | 85 ++++++++++ 4 files changed, 434 insertions(+), 34 deletions(-) diff --git a/internal/helpers/college_contact_test.go b/internal/helpers/college_contact_test.go index 244ac9f2..62d936a6 100644 --- a/internal/helpers/college_contact_test.go +++ b/internal/helpers/college_contact_test.go @@ -2,6 +2,7 @@ package helpers import ( "io" + "reflect" "strings" "testing" @@ -502,43 +503,165 @@ func TestCrossPlatformCoverageCollegeContactValidationErrors(t *testing.T) { } } -// TestCollegeContactDestructiveConfirmGate covers the `return err` branch of -// every destructive leaf command's confirm gate. A non-dry-run caller is -// installed (so collegeContactConfirmDestructive does not short-circuit) and -// each command is run with valid required flags but without --yes, so the -// confirm gate returns an error that the RunE closure propagates. +// TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate verifies every +// user_required destructive leaf in a paired manner: +// - Without --yes: returns confirmation_required error AND caller is never invoked (zero calls). +// - With --yes: proceeds to MCP dispatch with exactly one call AND the correct +// productID, tool name, and complete argument payload. func TestCrossPlatformCoverageCollegeContactDestructiveConfirmGate(t *testing.T) { - caller := &recruitCaptureCaller{dryRun: false} - InitDepsForTest(t, caller) - deps.Out.w = io.Discard - - cases := [][]string{ - {"college-contact", "alumni", "delete-dept", "--alumni-dept-id", "1"}, - {"college-contact", "alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"}, - {"college-contact", "alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"}, - {"college-contact", "alumni", "disband-group", "--alumni-dept-id", "1"}, - {"college-contact", "graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"}, - {"college-contact", "graduate", "all-graduate", "--graduate-year", "2026"}, - {"college-contact", "graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"}, - {"college-contact", "graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"}, - {"college-contact", "graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"}, - {"college-contact", "graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"}, - {"college-contact", "graduate", "commit-restore", "--graduate-dept-ids", "1,2"}, - {"college-contact", "group", "delete-group-rule", "--rule-id", "1"}, - {"college-contact", "group", "execute-group-rule"}, + type destructiveCase struct { + name string + args []string + wantTool string + wantInput map[string]any } - for _, args := range cases { - root := newCollegeContactTestRoot() - root.SetArgs(args) - err := root.Execute() - if err == nil { - t.Errorf("%v: expected confirm-gate error without --yes, got nil", args) - continue - } - if !strings.Contains(err.Error(), "需要用户确认") { - t.Errorf("%v: expected confirmation gate error, got: %v", args, err) - } + cases := []destructiveCase{ + { + "dept delete", + []string{"college-contact", "dept", "delete", "--dept-id", "123"}, + "delete_college_contact_dept", + map[string]any{"deptId": int64(123)}, + }, + { + "employee remove", + []string{"college-contact", "employee", "remove", "--staff-ids", "S1,S2"}, + "remove_employee", + map[string]any{"staffIds": []string{"S1", "S2"}}, + }, + { + "alumni delete-dept", + []string{"college-contact", "alumni", "delete-dept", "--alumni-dept-id", "1"}, + "delete_alumni_dept", + map[string]any{"alumniDeptId": int64(1)}, + }, + { + "alumni remove-alumnus", + []string{"college-contact", "alumni", "remove-alumnus", "--staff-id", "S1", "--alumni-dept-id", "1"}, + "delete_alumnus", + map[string]any{"staffId": "S1", "alumniDeptId": int64(1)}, + }, + { + "alumni cancel-invite", + []string{"college-contact", "alumni", "cancel-invite", "--alumni-dept-id", "1", "--staff-ids", "s1,s2"}, + "delete_alumni_invite_record", + map[string]any{"alumniDeptId": int64(1), "staffIds": []string{"s1", "s2"}}, + }, + { + "alumni disband-group", + []string{"college-contact", "alumni", "disband-group", "--alumni-dept-id", "1"}, + "disband_alumni_group", + map[string]any{"alumniDeptId": int64(1)}, + }, + { + "graduate commit-graduate", + []string{"college-contact", "graduate", "commit-graduate", "--graduate-dept-ids", "1,2", "--graduate-year", "2026"}, + "commit_graduate", + map[string]any{"graduateDeptIds": []int64{1, 2}, "graduateYear": int64(2026)}, + }, + { + "graduate all-graduate", + []string{"college-contact", "graduate", "all-graduate", "--graduate-year", "2026"}, + "all_graduate", + map[string]any{"graduateYear": int64(2026)}, + }, + { + "graduate batch-graduate", + []string{"college-contact", "graduate", "batch-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"}, + "batch_graduate", + map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}}, + }, + { + "graduate delete-and-graduate", + []string{"college-contact", "graduate", "delete-and-graduate", "--dept-id", "1", "--staff-ids", "s1,s2"}, + "delete_and_graduate", + map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}}, + }, + { + "graduate batch-delete-pending", + []string{"college-contact", "graduate", "batch-delete-pending", "--dept-id", "1", "--staff-ids", "s1,s2"}, + "batch_delete_pending", + map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}}, + }, + { + "graduate batch-update-pending", + []string{"college-contact", "graduate", "batch-update-pending", "--dept-id", "1", "--staff-ids", "s1,s2", "--graduate-year", "2026"}, + "batch_update_pending", + map[string]any{"deptId": int64(1), "staffIds": []string{"s1", "s2"}, "graduateYear": int64(2026)}, + }, + { + "graduate commit-restore", + []string{"college-contact", "graduate", "commit-restore", "--graduate-dept-ids", "1,2"}, + "commit_restore", + map[string]any{"graduateDeptIds": []int64{1, 2}}, + }, + { + "group delete-group-rule", + []string{"college-contact", "group", "delete-group-rule", "--rule-id", "1"}, + "delete_group_rule", + map[string]any{"ruleId": int64(1)}, + }, + { + "group execute-group-rule", + []string{"college-contact", "group", "execute-group-rule"}, + "execute_group_rule", + map[string]any{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) { + caller := &recruitCaptureCaller{dryRun: false} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + + root := newCollegeContactTestRoot() + root.SetArgs(tc.args) + err := root.Execute() + if err == nil { + t.Fatalf("expected confirm-gate error without --yes, got nil") + } + if !strings.Contains(err.Error(), "需要用户确认") { + t.Fatalf("expected confirmation gate error, got: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls)) + } + }) + + t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) { + caller := &recruitCaptureCaller{dryRun: false} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + + root := newCollegeContactTestRoot() + argsWithYes := append(append([]string{}, tc.args...), "--yes") + root.SetArgs(argsWithYes) + err := root.Execute() + if err != nil { + t.Fatalf("Execute() with --yes error = %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls)) + } + if caller.calls[0].productID != "college-contact" { + t.Errorf("productID = %q, want %q", caller.calls[0].productID, "college-contact") + } + if caller.calls[0].tool != tc.wantTool { + t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool) + } + gotArgs := caller.calls[0].args + if len(gotArgs) != 1 { + t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs) + } + gotInput, ok := gotArgs["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"]) + } + if !reflect.DeepEqual(gotInput, tc.wantInput) { + t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput) + } + }) } } diff --git a/internal/helpers/edu_app_test.go b/internal/helpers/edu_app_test.go index 01c5c9a0..e910ccc6 100644 --- a/internal/helpers/edu_app_test.go +++ b/internal/helpers/edu_app_test.go @@ -5,8 +5,11 @@ package helpers import ( "io" + "reflect" "strings" "testing" + + "github.com/spf13/cobra" ) func withEduAppCaller(t *testing.T) *recruitCaptureCaller { @@ -398,3 +401,103 @@ func TestCrossPlatformCoverageEduAppDispatch(t *testing.T) { } }) } + +// newEduAppConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册 +// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。 +func newEduAppConfirmRoot() *cobra.Command { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示") + root.AddCommand(newEduAppCommand()) + return root +} + +// TestCrossPlatformCoverageEduAppDestructiveConfirmGate 对 edu-app 每个 +// user_required 破坏性叶子做成对验证: +// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。 +// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。 +func TestCrossPlatformCoverageEduAppDestructiveConfirmGate(t *testing.T) { + cases := []struct { + name string + args []string + wantTool string + wantInput map[string]any + }{ + { + "notice delete", + []string{"edu-app", "notice", "delete", "--notice-id", "12345"}, + "delete_notice", + map[string]any{"noticeId": int64(12345)}, + }, + { + "notice delete with user-name", + []string{"edu-app", "notice", "delete", "--notice-id", "12345", "--user-name", "张三"}, + "delete_notice", + map[string]any{"noticeId": int64(12345), "userName": "张三"}, + }, + { + "homework delete", + []string{"edu-app", "homework", "delete", "--homework-id", "12345"}, + "delete_homework", + map[string]any{"homeworkId": int64(12345)}, + }, + { + "diploma delete", + []string{"edu-app", "diploma", "delete", "--diploma-id", "12345"}, + "delete_diploma", + map[string]any{"diplomaId": int64(12345)}, + }, + } + + for _, tc := range cases { + t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) { + caller := &recruitCaptureCaller{dryRun: false} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + + root := newEduAppConfirmRoot() + root.SetArgs(tc.args) + err := root.Execute() + if err == nil { + t.Fatalf("expected confirm-gate error without --yes, got nil") + } + if !strings.Contains(err.Error(), "需要用户确认") { + t.Fatalf("expected confirmation gate error, got: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls)) + } + }) + + t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) { + caller := &recruitCaptureCaller{dryRun: false} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + + root := newEduAppConfirmRoot() + root.SetArgs(append(append([]string{}, tc.args...), "--yes")) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() with --yes error = %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls)) + } + if caller.calls[0].productID != "edu-app" { + t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-app") + } + if caller.calls[0].tool != tc.wantTool { + t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool) + } + gotArgs := caller.calls[0].args + if len(gotArgs) != 1 { + t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs) + } + gotInput, ok := gotArgs["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"]) + } + if !reflect.DeepEqual(gotInput, tc.wantInput) { + t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput) + } + }) + } +} diff --git a/internal/helpers/edu_contact_test.go b/internal/helpers/edu_contact_test.go index d246b70c..53bcd6f1 100644 --- a/internal/helpers/edu_contact_test.go +++ b/internal/helpers/edu_contact_test.go @@ -2,6 +2,7 @@ package helpers import ( "io" + "reflect" "strings" "testing" @@ -581,3 +582,91 @@ func TestCrossPlatformCoverageEduContactDispatch(t *testing.T) { } }) } + +// newEduContactConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册 +// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。 +func newEduContactConfirmRoot() *cobra.Command { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示") + root.AddCommand(newEduContactCommand()) + return root +} + +// TestCrossPlatformCoverageEduContactDestructiveConfirmGate 对 edu-contact 每个 +// user_required 破坏性叶子做成对验证: +// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。 +// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。 +func TestCrossPlatformCoverageEduContactDestructiveConfirmGate(t *testing.T) { + cases := []struct { + name string + args []string + wantTool string + wantInput map[string]any + }{ + { + "class delete-teacher", + []string{"edu-contact", "class", "delete-teacher", "--class-id", "12345", "--teacher-user-id", "userId1"}, + "delete_teacher", + map[string]any{"classId": int64(12345), "teacherUserId": "userId1"}, + }, + { + "class delete-students", + []string{"edu-contact", "class", "delete-students", "--dept-id", "12345", "--student-user-ids", "userId1,userId2"}, + "delete_students", + map[string]any{"deptId": int64(12345), "studentUserIds": []string{"userId1", "userId2"}}, + }, + } + + for _, tc := range cases { + t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) { + caller := &recruitCaptureCaller{dryRun: false} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + + root := newEduContactConfirmRoot() + root.SetArgs(tc.args) + err := root.Execute() + if err == nil { + t.Fatalf("expected confirm-gate error without --yes, got nil") + } + if !strings.Contains(err.Error(), "需要用户确认") { + t.Fatalf("expected confirmation gate error, got: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls)) + } + }) + + t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) { + caller := &recruitCaptureCaller{dryRun: false} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + + root := newEduContactConfirmRoot() + root.SetArgs(append(append([]string{}, tc.args...), "--yes")) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() with --yes error = %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls)) + } + if caller.calls[0].productID != "edu-contact" { + t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-contact") + } + if caller.calls[0].tool != tc.wantTool { + t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool) + } + gotArgs := caller.calls[0].args + if len(gotArgs) != 1 { + t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs) + } + gotInput, ok := gotArgs["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"]) + } + if !reflect.DeepEqual(gotInput, tc.wantInput) { + t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput) + } + }) + } +} diff --git a/internal/helpers/edu_group_test.go b/internal/helpers/edu_group_test.go index d96038a9..2b78e0be 100644 --- a/internal/helpers/edu_group_test.go +++ b/internal/helpers/edu_group_test.go @@ -5,8 +5,11 @@ package helpers import ( "io" + "reflect" "strings" "testing" + + "github.com/spf13/cobra" ) // withEduGroupCaller installs a dry-run capture caller so happy-path command @@ -279,3 +282,85 @@ func TestCrossPlatformCoverageEduGroupDispatch(t *testing.T) { } }) } + +// newEduGroupConfirmRoot 模拟真实运行时的根命令:核心框架在 rootCmd 上注册 +// 全局 persistent --yes flag,叶子命令通过合并后的 Flags() 读取。 +func newEduGroupConfirmRoot() *cobra.Command { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().BoolP("yes", "y", false, "跳过确认提示") + root.AddCommand(newEduGroupCommand()) + return root +} + +// TestCrossPlatformCoverageEduGroupDestructiveConfirmGate 对 edu-group 每个 +// user_required 破坏性叶子做成对验证: +// - 未显式确认:返回 confirmation_required 错误,且 caller 调用次数为零。 +// - 显式确认后:恰好一次 MCP 调用,且 productID、tool、完整参数均准确。 +func TestCrossPlatformCoverageEduGroupDestructiveConfirmGate(t *testing.T) { + cases := []struct { + name string + args []string + wantTool string + wantInput map[string]any + }{ + { + "student-group disband", + []string{"edu-group", "student-group", "disband", "--dept-id", "12345"}, + "disband_class_group", + map[string]any{"deptId": int64(12345)}, + }, + } + + for _, tc := range cases { + t.Run(tc.name+"/rejected_without_yes", func(t *testing.T) { + caller := &recruitCaptureCaller{dryRun: false} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + + root := newEduGroupConfirmRoot() + root.SetArgs(tc.args) + err := root.Execute() + if err == nil { + t.Fatalf("expected confirm-gate error without --yes, got nil") + } + if !strings.Contains(err.Error(), "需要用户确认") { + t.Fatalf("expected confirmation gate error, got: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("caller should not be invoked without --yes, got %d calls", len(caller.calls)) + } + }) + + t.Run(tc.name+"/dispatched_with_yes", func(t *testing.T) { + caller := &recruitCaptureCaller{dryRun: false} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + + root := newEduGroupConfirmRoot() + root.SetArgs(append(append([]string{}, tc.args...), "--yes")) + if err := root.Execute(); err != nil { + t.Fatalf("Execute() with --yes error = %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("expected exactly 1 MCP call with --yes, got %d", len(caller.calls)) + } + if caller.calls[0].productID != "edu-group" { + t.Errorf("productID = %q, want %q", caller.calls[0].productID, "edu-group") + } + if caller.calls[0].tool != tc.wantTool { + t.Errorf("tool = %q, want %q", caller.calls[0].tool, tc.wantTool) + } + gotArgs := caller.calls[0].args + if len(gotArgs) != 1 { + t.Fatalf("args should carry exactly the \"input\" key, got %v", gotArgs) + } + gotInput, ok := gotArgs["input"].(map[string]any) + if !ok { + t.Fatalf("args[\"input\"] should be map[string]any, got %T", gotArgs["input"]) + } + if !reflect.DeepEqual(gotInput, tc.wantInput) { + t.Errorf("input = %#v, want %#v", gotInput, tc.wantInput) + } + }) + } +} From 426ad50927e4fc983c073b5a1556ed3b8684ae2c Mon Sep 17 00:00:00 2001 From: "muling.cs" Date: Mon, 24 Aug 2026 18:06:05 +0800 Subject: [PATCH 12/33] refresh use legacy global slot --- internal/auth/oauth_provider.go | 126 +++++++ internal/auth/refresh_failure_test.go | 472 ++++++++++++++++++++++++++ 2 files changed, 598 insertions(+) diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 02ad2380..219693ce 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -828,6 +828,12 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) { fallback, fErr := p.refreshFromOrgSlot(ctx, data) if fErr != nil { logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr) + // The organization mirror may be absent for long-lived local logins + // that predate mirror publication. Recover from the legacy global + // slot before giving up. + if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil { + return recovered, nil + } return nil, rErr } if p.logger != nil { @@ -891,6 +897,126 @@ func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenDa return refreshed, nil } +func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) { + var exchangeErr *MCPTokenExchangeError + if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() { + return nil, refreshErr + } + if selected == nil { + return nil, refreshErr + } + slog.Debug("auth.refresh.legacy_recovery.triggered", + "corp_id", strings.TrimSpace(selected.CorpID), + "user_id", strings.TrimSpace(selected.UserID), + "refresh_error_code", exchangeErr.Code, + ) + legacy, loadErr := tokenLoadKeychain() + if loadErr != nil { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr) + return nil, refreshErr + } + if legacy == nil { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy") + return nil, refreshErr + } + if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) { + slog.Debug("auth.refresh.legacy_recovery.failed", + "step", "candidate_mismatch", + "legacy_corp_id", strings.TrimSpace(legacy.CorpID), + "legacy_user_id", strings.TrimSpace(legacy.UserID), + ) + return nil, refreshErr + } + recovered := *legacy + if strings.TrimSpace(recovered.UserID) == "" { + recovered.UserID = strings.TrimSpace(selected.UserID) + } + if strings.TrimSpace(recovered.UserName) == "" { + recovered.UserName = strings.TrimSpace(selected.UserName) + } + if recovered.IsAccessTokenValid() { + if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err) + return nil, refreshErr + } + slog.Debug("auth.refresh.legacy_recovery.success", "via", "valid_access_token") + return &recovered, nil + } + if !recovered.IsRefreshTokenValid() { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired") + return nil, refreshErr + } + if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token") + return nil, refreshErr + } + if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err) + return nil, refreshErr + } + refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered) + if recoverErr != nil { + slog.Debug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr) + return nil, refreshErr + } + slog.Debug("auth.refresh.legacy_recovery.success", "via", "refresh") + return refreshed, nil +} + +func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool { + if selected == nil || legacy == nil { + return false + } + selectedCorpID := strings.TrimSpace(selected.CorpID) + legacyCorpID := strings.TrimSpace(legacy.CorpID) + if selectedCorpID == "" || legacyCorpID != selectedCorpID { + return false + } + selectedUserID := strings.TrimSpace(selected.UserID) + legacyUserID := strings.TrimSpace(legacy.UserID) + if selectedUserID == "" { + return legacyUserID == "" + } + if legacyUserID != "" { + return legacyUserID == selectedUserID + } + return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID) +} + +func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool { + if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" { + return false + } + cfg, err := tokenLoadProfiles(configDir) + if err != nil || cfg == nil { + slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err) + return false + } + profiles := profilesForCorpID(cfg, corpID) + if len(profiles) != 1 { + slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", + "reason", "multi_profile", + "corp_id", strings.TrimSpace(corpID), + "profile_count", len(profiles), + ) + return false + } + profile := profiles[0] + if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) { + return true + } + profileUserID := "" + if profile != nil { + profileUserID = strings.TrimSpace(profile.UserID) + } + slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", + "reason", "identity_mismatch", + "selected_user_id", strings.TrimSpace(userID), + "profile_user_id", profileUserID, + ) + return false +} + // ExchangeAuthCode takes an AuthCode and an optional UserID provided by an // external host, exchanges it for tokens, and persists them. func (p *OAuthProvider) ExchangeAuthCode(ctx context.Context, authCode, uid string) (*TokenData, error) { diff --git a/internal/auth/refresh_failure_test.go b/internal/auth/refresh_failure_test.go index 5281270f..9db8bf91 100644 --- a/internal/auth/refresh_failure_test.go +++ b/internal/auth/refresh_failure_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -189,6 +190,150 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR } } +func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039", + UserID: "user-v1039", + UserName: "V1039 User", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{ + Name: "V1039 User", + CorpID: selected.CorpID, + UserID: selected.UserID, + UserName: selected.UserName, + }}}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + var saved *TokenData + testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error { + copy := *data + saved = © + return nil + }) + + recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if err != nil { + t.Fatalf("lockedRefresh() error = %v", err) + } + if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken { + t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy) + } + if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName { + t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName) + } + if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID { + t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered) + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-multi", + UserID: "user-v1039-a", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{ + {Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID}, + {Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"}, + }}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-user-mismatch", + UserID: "user-v1039-selected", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: "user-v1039-other", + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a token owned by a different user") + } +} + func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) { fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true) expired := *fixture.blankToken @@ -253,3 +398,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola } } } + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-single-mismatch", + UserID: "user-v1039-selected", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{ + Name: "Other User", + CorpID: selected.CorpID, + UserID: "user-v1039-other", + }}}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-legacy-refresh", + UserID: "user-v1039", + UserName: "V1039 User", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "expired-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + refreshed := &TokenData{ + AccessToken: "refreshed-legacy-access", + RefreshToken: "rotated-legacy-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + refreshCalls := 0 + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + refreshCalls++ + if refreshCalls == 1 { + return nil, rejection + } + return refreshed, nil + }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + + recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if err != nil { + t.Fatalf("lockedRefresh() error = %v", err) + } + if refreshCalls != 2 { + t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls) + } + if recovered.AccessToken != refreshed.AccessToken { + t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken) + } + if recovered.RefreshToken != refreshed.RefreshToken { + t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken) + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-profiles-error", + UserID: "user-v1039", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "shared-rejected-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-same-refresh", + UserID: "user-v1039", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "expired-legacy-global-access", + RefreshToken: selected.RefreshToken, + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token") + } +} + +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) { + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + provider := NewOAuthProvider(t.TempDir(), nil) + + // nil selected must be rejected before any dereference. + if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) { + t.Fatalf("nil selected error = %v, want original rejection", err) + } + + // A keychain load that returns (nil, nil) must be rejected before any dereference. + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil }) + selected := &TokenData{ + CorpID: "corp-v1039-nil-legacy", + UserID: "user-v1039", + Source: "mcp", + } + if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("nil legacy error = %v, want original rejection", err) + } +} + +func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) { + provider := NewOAuthProvider(t.TempDir(), nil) + selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"} + + plainErr := errors.New("plain refresh failure") + if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) { + t.Fatalf("plain error = %v, want original plain failure", err) + } +} + +func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) { + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + selected := &TokenData{ + CorpID: "corp-v1039-recovery-steps", + UserID: "user-v1039", + Source: "mcp", + } + + t.Run("save_failure", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "valid-legacy-access", + RefreshToken: "valid-legacy-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("save failure error = %v, want original rejection", err) + } + }) + + t.Run("refresh_expired", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "expired-legacy-access", + RefreshToken: "expired-legacy-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(-time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("expired refresh error = %v, want original rejection", err) + } + }) + + t.Run("refresh_error", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "expired-legacy-access", + RefreshToken: "valid-legacy-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { + return nil, errors.New("legacy refresh failed") + }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("legacy refresh error = %v, want original rejection", err) + } + }) + + t.Run("preflight_error", func(t *testing.T) { + legacy := &TokenData{ + AccessToken: "expired-legacy-access", + RefreshToken: "valid-legacy-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + UserID: selected.UserID, + Source: "mcp", + } + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") }) + if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) { + t.Fatalf("preflight error = %v, want original rejection", err) + } + }) +} + +func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) { + configDir := t.TempDir() + selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"} + + if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) { + t.Fatal("nil legacy accepted") + } + if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) { + t.Fatal("different corp accepted") + } + if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) { + t.Fatal("blank selected corp accepted") + } + + blankSelected := &TokenData{CorpID: selected.CorpID} + if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) { + t.Fatal("both blank user IDs should match") + } + if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) { + t.Fatal("blank selected with non-blank legacy accepted") + } + + if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) { + t.Fatal("blank corp accepted by single-profile check") + } + if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selected.CorpID, "") { + t.Fatal("blank user accepted by single-profile check") + } +} From 0bfb1ff1deda79ddd1890f2aa861d32cbb35f7b3 Mon Sep 17 00:00:00 2001 From: "muling.cs" Date: Mon, 24 Aug 2026 18:11:07 +0800 Subject: [PATCH 13/33] chore: add release fragment for legacy global slot recovery --- .changes/legacy-global-slot-recovery.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/legacy-global-slot-recovery.md diff --git a/.changes/legacy-global-slot-recovery.md b/.changes/legacy-global-slot-recovery.md new file mode 100644 index 00000000..daaf9dab --- /dev/null +++ b/.changes/legacy-global-slot-recovery.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations. From 662352202cc800eba67e4a69f284273e2bd3f71a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=8D=B7?= Date: Mon, 24 Aug 2026 18:33:08 +0800 Subject: [PATCH 14/33] docs(skills): complete edu-app command surface and fix dead cross-product ref MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skill 参考此前只描述了 edu-app 42 条叶子里的 16 条,homework(11)/diploma(9) 与 notice 的 6 条全部缺失,导致 Agent 把"作业/通知"一律路由到 task 与 notice confirm;三条 user_required 破坏性命令(notice/homework/diploma delete)也落在未记录区间,PR 里的确认门禁说明因此无法被文档兑现。 edu-familygroup 的上下文传递表引用了不存在的 edu-contact family list, 真正返回家长信息的是 family parents。 check-skill-commands.sh 只校验以 `dws ` 开头的反引号片段,因此上述两处 死引用能在门禁全绿的情况下存活;本次给跨产品引用统一补上 dws 前缀, 让该门禁从现在起覆盖它们。 同时修正扫描测试的陈旧计数(153→156、edu-contact 26→29、class 18→19, 以 DeclareLeafMetadata 声明数为准),并给 edu-familygroup 第 6 条叶子 manage add-child 补上工具名与整体入参的精确断言。 --- internal/helpers/edu_family_group_test.go | 32 ++++++ internal/helpers/run_all_edu_commands_test.go | 6 +- skills/mono/references/products/edu-app.md | 104 ++++++++++++++++-- .../references/products/edu-familygroup.md | 2 +- .../multi/dingtalk-misc/references/edu-app.md | 101 +++++++++++++++-- .../references/edu-familygroup.md | 2 +- 6 files changed, 222 insertions(+), 25 deletions(-) diff --git a/internal/helpers/edu_family_group_test.go b/internal/helpers/edu_family_group_test.go index 59d7eb2c..8d25c149 100644 --- a/internal/helpers/edu_family_group_test.go +++ b/internal/helpers/edu_family_group_test.go @@ -5,6 +5,7 @@ package helpers import ( "io" + "reflect" "strings" "testing" ) @@ -283,4 +284,35 @@ func TestCrossPlatformCoverageEduFamilyGroupDispatch(t *testing.T) { t.Fatalf("input[\"open\"] = %#v, want true", input["open"]) } }) + + t.Run("add-child", func(t *testing.T) { + caller := withEduFamilyGroupDispatchCaller(t) + cmd := newEduFamilyGroupCommand() + cmd.SetArgs([]string{"manage", "add-child", "--org-id", "12345", "--uid", "67890", + "--name", "小明", "--mobile", "13900139000", + "--students", `[{"schoolOrgId":111,"studentStaffId":"stu001"}]`}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() = %v", err) + } + if caller.productID != "edu-familygroup" { + t.Fatalf("productID = %q, want %q", caller.productID, "edu-familygroup") + } + if caller.tool != "add_child_to_family_group" { + t.Fatalf("tool = %q, want %q", caller.tool, "add_child_to_family_group") + } + want := map[string]any{ + "input": map[string]any{ + "orgId": int64(12345), + "uid": int64(67890), + "name": "小明", + "mobile": "13900139000", + "students": []any{ + map[string]any{"schoolOrgId": float64(111), "studentStaffId": "stu001"}, + }, + }, + } + if !reflect.DeepEqual(caller.args, want) { + t.Fatalf("args = %#v, want %#v", caller.args, want) + } + }) } diff --git a/internal/helpers/run_all_edu_commands_test.go b/internal/helpers/run_all_edu_commands_test.go index 3c645141..35c6ef77 100644 --- a/internal/helpers/run_all_edu_commands_test.go +++ b/internal/helpers/run_all_edu_commands_test.go @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -// TestRunAllEduCommands 逐条执行 5 个教育产品的全部 153 条叶子命令(dry-run 模式), +// TestRunAllEduCommands 逐条执行 5 个教育产品的全部 156 条叶子命令(dry-run 模式), // 并打印每条命令对应的 MCP 工具名和参数。相当于在终端逐一执行 dws --dry-run。 func TestRunAllEduCommands(t *testing.T) { type cmdCase struct { @@ -19,7 +19,7 @@ func TestRunAllEduCommands(t *testing.T) { allCmds := []cmdCase{ // ═══════════════════════════════════════════════════════════ - // edu-contact: 26 commands + // edu-contact: 29 commands // ═══════════════════════════════════════════════════════════ // school (6) {"edu-contact", []string{"school", "roles"}}, @@ -28,7 +28,7 @@ func TestRunAllEduCommands(t *testing.T) { {"edu-contact", []string{"school", "type"}}, {"edu-contact", []string{"school", "stats", "--statistics-type", "1"}}, {"edu-contact", []string{"school", "class-list"}}, - // class (18) + // class (19) {"edu-contact", []string{"class", "detail", "--dept-id", "12345"}}, {"edu-contact", []string{"class", "students", "--dept-id", "12345"}}, {"edu-contact", []string{"class", "teachers", "--dept-id", "12345"}}, diff --git a/skills/mono/references/products/edu-app.md b/skills/mono/references/products/edu-app.md index 22349119..e310a079 100644 --- a/skills/mono/references/products/edu-app.md +++ b/skills/mono/references/products/edu-app.md @@ -35,7 +35,17 @@ | 命令 | 用途 | 必填参数 | |------|------|----------| +| `notice create` | 创建并发布通知 | `--identifer`, `--content` | +| `notice get` | 查询通知详情 | `--notice-id` | +| `notice list-by-teacher` | 查询老师发布的通知列表 | 无(均可选) | +| `notice list-by-student` | 查询学生通知列表 | `--student-id`, `--class-id` | | `notice confirm` | 确认收到通知 | `--notice-id`, `--student-id` | +| `notice confirm-status` | 查询通知确认状态 | `--notice-id`, `--class-id` | +| `notice delete` | 删除通知(破坏性,需 `--yes`) | `--notice-id` | + +> `notice create` 的幂等字段拼写为 `--identifer`(少一个 i),与上游字段 `input.identifer` 一致,不要写成 `--identifier`;建议格式 `orgId-staffId-UUID` +> `notice create --target-role`: guardian / student;`--is-signed true` 表示需要签收 +> `notice list-by-teacher --status` / `notice list-by-student --status` 用于筛选通知状态 ### circle (班级圈) @@ -61,17 +71,69 @@ > `card finish-info --target-role`: teacher / headmaster / guardian / student,未传时按 uid 真实身份自动推断 > `card finish-info` 当 `targetRole=guardian` 时,可传 `--student-id` 指定查看某个孩子的进度 +### homework (作业管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `homework create` | 创建并发布作业 | `--identifier`, `--hw-content` | +| `homework get` | 查询作业详情 | `--homework-id` | +| `homework list-by-teacher` | 查询老师作业列表 | 无(均可选) | +| `homework list-by-student` | 查询学生作业列表 | `--student-id`, `--class-id`, `--user-name` | +| `homework class-by-homework` | 查询作业的班级提交情况 | `--homework-id` | +| `homework class-detail` | 查询班级作业详情 | `--homework-id`, `--class-id`, `--user-name` | +| `homework submit-statistics` | 查询作业提交统计 | `--homework-id`, `--class-id` | +| `homework student-detail` | 查询学生作业详情 | `--homework-id`, `--student-id`, `--class-id` | +| `homework submit` | 提交作业 | `--hw-content-detail-id` | +| `homework create-comment` | 创建作业评语 | `--comment`, `--hw-content-detail-id` | +| `homework delete` | 删除作业(破坏性,需 `--yes`) | `--homework-id` | + +> 作业正文用 `--hw-content`(不是 `--content`);`--hw-title` 为可选标题 +> `--submit-types` / `--hw-type` / `--publish-type` 控制提交方式与作业类型 +> `homework submit` 与 `homework create-comment` 定位到具体作业内容用 `--hw-content-detail-id` + +### diploma (奖状管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `diploma create` | 创建并颁发奖状 | `--identifier`, `--content`, `--user-name` | +| `diploma get` | 查询奖状详情 | `--diploma-id` | +| `diploma list-by-teacher` | 查询老师创建的奖状列表 | 无(均可选) | +| `diploma list-by-student` | 查询学生收到的奖状列表 | `--student-id`, `--class-id` | +| `diploma detail` | 查询奖状接收详情 | `--diploma-id` | +| `diploma student-detail` | 查询学生奖状接收详情 | `--diploma-id`, `--student-id`, `--class-id` | +| `diploma statistics` | 查询奖状阅读统计 | `--diploma-id` | +| `diploma read` | 标记奖状为已读 | `--diploma-id` | +| `diploma delete` | 删除奖状(破坏性,需 `--yes`) | `--diploma-id` | + +> diploma 是「奖状」,不是毕业证书;`--tag` 用于奖状类别,`--template-url` 指定奖状模板 + +## 危险操作 + +以下三条为 `user_required` 破坏性命令,不加 `--yes` 会被确认门禁拦下(`category: validation`, `code: 3`, `reason: confirmation_required`,退出码 3): + +| 命令 | 后果 | +|------|------| +| `notice delete --notice-id --yes` | 删除通知,家长/学生侧不可恢复 | +| `homework delete --homework-id --yes` | 删除作业及其提交记录 | +| `diploma delete --diploma-id --yes` | 删除已颁发的奖状 | + +其余命令均为读或普通写操作,不需要 `--yes`。 + ## 意图判断 用户说"消息/消息摘要" → message summary-list -用户说"作业/任务/打卡/家校任务" → task 子命令 +用户说"家校任务/待办任务" → task 子命令 +用户说"作业" → homework 子命令(发布→create,查详情→get,批改评语→create-comment,提交→submit,删除→delete + `--yes`) 用户说"成绩/成绩单" → report 子命令 -用户说"通知/确认通知" → notice confirm +用户说"通知" → notice 子命令(发通知→create,查详情→get,签收/确认→confirm,查签收情况→confirm-status,删除→delete + `--yes`) +用户说"奖状/表彰/颁奖" → diploma 子命令(颁发→create,查详情→get,阅读统计→statistics,删除→delete + `--yes`) 用户说"班级圈/成长记录/学生动态" → circle posts 用户说"打卡/打卡任务/打卡完成情况/卡片完成情况" → card 子命令 -关键区分: task(家校任务/作业) vs report(成绩单) +关键区分: homework(作业,独立命令组) vs task(家校任务聚合列表,含作业/打卡/通知/奖状等来源) 关键区分: circle(班级圈动态/成长记录) vs message(AI消息总结) +关键区分: diploma(奖状/表彰) vs report(成绩单) +老师视角用 `list-by-teacher`,学生/家长视角用 `list-by-student`,homework / notice / diploma 三组同构。 ## 核心工作流 @@ -85,25 +147,45 @@ 7. 查看某班打卡完成情况 → `card user-statistic --card-id --task-code --class-id --finish` 8. 查看某班未打卡人员 → `card user-statistic --card-id --task-code --class-id ` 9. 查看某打卡完成进度 → `card finish-info --card-id --card-biz-id ` +10. 发布作业 → `homework create --identifier --hw-content "第三章习题" --class-ids ` +11. 查看作业提交统计 → `homework submit-statistics --homework-id --class-id ` +12. 批改作业写评语 → `homework create-comment --hw-content-detail-id --comment "写得很好"` +13. 删除作业 → `homework delete --homework-id --yes` +14. 发布通知 → `notice create --identifer --content "明天放假" --class-ids --is-signed true` +15. 查看通知签收情况 → `notice confirm-status --notice-id --class-id ` +16. 删除通知 → `notice delete --notice-id --yes` +17. 颁发奖状 → `diploma create --identifier --content "三好学生" --user-name <老师姓名> --class-ids ` +18. 查看奖状阅读统计 → `diploma statistics --diploma-id ` +19. 删除奖状 → `diploma delete --diploma-id --yes` ### 家长场景 1. 查看孩子待办 → `task student-list --students '[{"userId":"","bizId":""}]'` 2. 确认通知 → `notice confirm --notice-id --student-id ` -3. 查看孩子班级圈动态 → `circle posts --class-id --student-id --target-role guardian` -4. 查看孩子进行中打卡 → `card list --status UNFINISH` -5. 查看孩子某打卡进度 → `card finish-info --card-id --card-biz-id ` +3. 查看孩子收到的通知 → `notice list-by-student --student-id --class-id ` +4. 查看孩子班级圈动态 → `circle posts --class-id --student-id --target-role guardian` +5. 查看孩子进行中打卡 → `card list --status UNFINISH` +6. 查看孩子某打卡进度 → `card finish-info --card-id --card-biz-id ` +7. 查看孩子作业列表 → `homework list-by-student --student-id --class-id --user-name <家长姓名>` +8. 查看孩子收到的奖状 → `diploma list-by-student --student-id --class-id ` ### 学生场景 1. 查看自己的班级圈动态 → `circle posts --class-id --student-id --target-role student` +2. 提交作业 → `homework submit --hw-content-detail-id --content "已完成"` +3. 查看某份作业详情 → `homework student-detail --homework-id --student-id --class-id ` +4. 标记奖状已读 → `diploma read --diploma-id ` ## 上下文传递表 | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `edu-contact school class-list` | `deptId` | task all-list 的 --biz-id | -| `edu-contact class students` | `userId` | task student-list 的 students.userId | -| `edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid | +| `dws edu-contact school class-list` | `deptId` | task all-list 的 --biz-id | +| `dws edu-contact class students` | `userId` | task student-list 的 students.userId | +| `dws edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid | | `report by-teacher` | `schoolReportId` | report get/by-class/by-student-detail 的 --report-id | -| `edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id | -| `task publish-list` | `cardId` | card update/end/finish-info` 的 --card-id | +| `dws edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id | +| `task publish-list` | `cardId` | card update/end/finish-info 的 --card-id | | `task publish-list` | `taskCode` | card user-statistic 的 --task-code | +| `homework list-by-teacher` | `homeworkId` | homework get/delete/submit-statistics 的 --homework-id | +| `homework class-detail` | `hwContentDetailId` | homework submit / create-comment 的 --hw-content-detail-id | +| `notice list-by-teacher` | `noticeId` | notice get/confirm/confirm-status/delete 的 --notice-id | +| `diploma list-by-teacher` | `diplomaId` | diploma get/detail/statistics/delete 的 --diploma-id | diff --git a/skills/mono/references/products/edu-familygroup.md b/skills/mono/references/products/edu-familygroup.md index 82ec8631..cf49b4ae 100644 --- a/skills/mono/references/products/edu-familygroup.md +++ b/skills/mono/references/products/edu-familygroup.md @@ -65,4 +65,4 @@ |------|-------------|------| | `manage create` | `orgId`, `cid` | invite-parent / add-child 的 --org-id | | `group list-children` | 孩子 staffId | toggle-app 的 --child-staff-id | -| `edu-contact family list` | 家长 uid | 所有 edu-familygroup 命令的 --uid | +| `dws edu-contact family parents` | 家长 uid | 所有 edu-familygroup 命令的 --uid | diff --git a/skills/multi/dingtalk-misc/references/edu-app.md b/skills/multi/dingtalk-misc/references/edu-app.md index 256bffd7..0a5cf3da 100644 --- a/skills/multi/dingtalk-misc/references/edu-app.md +++ b/skills/multi/dingtalk-misc/references/edu-app.md @@ -35,7 +35,16 @@ | 命令 | 用途 | 必填参数 | |------|------|----------| +| `notice create` | 创建并发布通知 | `--identifer`, `--content` | +| `notice get` | 查询通知详情 | `--notice-id` | +| `notice list-by-teacher` | 查询老师发布的通知列表 | 无(均可选) | +| `notice list-by-student` | 查询学生通知列表 | `--student-id`, `--class-id` | | `notice confirm` | 确认收到通知 | `--notice-id`, `--student-id` | +| `notice confirm-status` | 查询通知确认状态 | `--notice-id`, `--class-id` | +| `notice delete` | 删除通知(破坏性,需 `--yes`) | `--notice-id` | + +> `notice create` 的幂等字段拼写为 `--identifer`(少一个 i),与上游字段 `input.identifer` 一致,不要写成 `--identifier` +> `notice create --target-role`: guardian / student;`--is-signed true` 表示需要签收 ### circle (班级圈) @@ -60,17 +69,68 @@ > `card finish-info --target-role`: teacher / headmaster / guardian / student,未传时按 uid 真实身份自动推断 > `card finish-info --student-id`: 当 targetRole 为 guardian 时,用于指定查看某个孩子的进度 +### homework (作业管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `homework create` | 创建并发布作业 | `--identifier`, `--hw-content` | +| `homework get` | 查询作业详情 | `--homework-id` | +| `homework list-by-teacher` | 查询老师作业列表 | 无(均可选) | +| `homework list-by-student` | 查询学生作业列表 | `--student-id`, `--class-id`, `--user-name` | +| `homework class-by-homework` | 查询作业的班级提交情况 | `--homework-id` | +| `homework class-detail` | 查询班级作业详情 | `--homework-id`, `--class-id`, `--user-name` | +| `homework submit-statistics` | 查询作业提交统计 | `--homework-id`, `--class-id` | +| `homework student-detail` | 查询学生作业详情 | `--homework-id`, `--student-id`, `--class-id` | +| `homework submit` | 提交作业 | `--hw-content-detail-id` | +| `homework create-comment` | 创建作业评语 | `--comment`, `--hw-content-detail-id` | +| `homework delete` | 删除作业(破坏性,需 `--yes`) | `--homework-id` | + +> 作业正文用 `--hw-content`(不是 `--content`);`--hw-title` 为可选标题 +> `homework submit` 与 `homework create-comment` 定位到具体作业内容用 `--hw-content-detail-id` + +### diploma (奖状管理) + +| 命令 | 用途 | 必填参数 | +|------|------|----------| +| `diploma create` | 创建并颁发奖状 | `--identifier`, `--content`, `--user-name` | +| `diploma get` | 查询奖状详情 | `--diploma-id` | +| `diploma list-by-teacher` | 查询老师创建的奖状列表 | 无(均可选) | +| `diploma list-by-student` | 查询学生收到的奖状列表 | `--student-id`, `--class-id` | +| `diploma detail` | 查询奖状接收详情 | `--diploma-id` | +| `diploma student-detail` | 查询学生奖状接收详情 | `--diploma-id`, `--student-id`, `--class-id` | +| `diploma statistics` | 查询奖状阅读统计 | `--diploma-id` | +| `diploma read` | 标记奖状为已读 | `--diploma-id` | +| `diploma delete` | 删除奖状(破坏性,需 `--yes`) | `--diploma-id` | + +> diploma 是「奖状」,不是毕业证书;`--tag` 用于奖状类别,`--template-url` 指定奖状模板 + +## 危险操作 + +以下三条为 `user_required` 破坏性命令,不加 `--yes` 会被确认门禁拦下(`category: validation`, `code: 3`, `reason: confirmation_required`,退出码 3): + +| 命令 | 后果 | +|------|------| +| `notice delete --notice-id --yes` | 删除通知,家长/学生侧不可恢复 | +| `homework delete --homework-id --yes` | 删除作业及其提交记录 | +| `diploma delete --diploma-id --yes` | 删除已颁发的奖状 | + +其余命令均为读或普通写操作,不需要 `--yes`。 + ## 意图判断 用户说"消息/消息摘要" → message summary-list -用户说"作业/任务/打卡/家校任务" → task 子命令 +用户说"家校任务/待办任务" → task 子命令 +用户说"作业" → homework 子命令(发布→create,查详情→get,批改评语→create-comment,提交→submit,删除→delete + `--yes`) 用户说"成绩/成绩单" → report 子命令 -用户说"通知/确认通知" → notice confirm +用户说"通知" → notice 子命令(发通知→create,查详情→get,签收/确认→confirm,查签收情况→confirm-status,删除→delete + `--yes`) +用户说"奖状/表彰/颁奖" → diploma 子命令(颁发→create,查详情→get,阅读统计→statistics,删除→delete + `--yes`) 用户说"班级圈/成长记录/学生动态" → circle posts -用户说"打卡/打卡列表/打卡进度/打卡统计/卡片完成情况" → card 子命令 +用户说"打卡/打卡任务/打卡完成情况/卡片完成情况" → card 子命令 -关键区分: task(家校任务/作业) vs report(成绩单) +关键区分: homework(作业,独立命令组) vs task(家校任务聚合列表,含作业/打卡/通知/奖状等来源) 关键区分: circle(班级圈动态/成长记录) vs message(AI消息总结) +关键区分: diploma(奖状/表彰) vs report(成绩单) +老师视角用 `list-by-teacher`,学生/家长视角用 `list-by-student`,homework / notice / diploma 三组同构。 ## 核心工作流 @@ -79,24 +139,47 @@ 2. 查看某班全部任务 → `task all-list --biz-id ` 3. 查看成绩单 → `report by-teacher --status 1` 4. 查看班级成绩明细 → `report by-class --report-id --class-id ` +5. 查看某班打卡完成情况 → `card user-statistic --card-id --task-code --class-id --finish` +6. 发布作业 → `homework create --identifier --hw-content "第三章习题" --class-ids ` +7. 查看作业提交统计 → `homework submit-statistics --homework-id --class-id ` +8. 批改作业写评语 → `homework create-comment --hw-content-detail-id --comment "写得很好"` +9. 删除作业 → `homework delete --homework-id --yes` +10. 发布通知 → `notice create --identifer --content "明天放假" --class-ids --is-signed true` +11. 查看通知签收情况 → `notice confirm-status --notice-id --class-id ` +12. 删除通知 → `notice delete --notice-id --yes` +13. 颁发奖状 → `diploma create --identifier --content "三好学生" --user-name <老师姓名> --class-ids ` +14. 查看奖状阅读统计 → `diploma statistics --diploma-id ` +15. 删除奖状 → `diploma delete --diploma-id --yes` ### 家长场景 1. 查看孩子待办 → `task student-list --students '[{"userId":"","bizId":""}]'` 2. 确认通知 → `notice confirm --notice-id --student-id ` -3. 查看孩子班级圈动态 → `circle posts --class-id --student-id --target-role guardian` +3. 查看孩子收到的通知 → `notice list-by-student --student-id --class-id ` +4. 查看孩子班级圈动态 → `circle posts --class-id --student-id --target-role guardian` +5. 查看孩子进行中打卡 → `card list --status UNFINISH` +6. 查看孩子作业列表 → `homework list-by-student --student-id --class-id --user-name <家长姓名>` +7. 查看孩子收到的奖状 → `diploma list-by-student --student-id --class-id ` ### 学生场景 1. 查看自己的班级圈动态 → `circle posts --class-id --student-id --target-role student` +2. 提交作业 → `homework submit --hw-content-detail-id --content "已完成"` +3. 标记奖状已读 → `diploma read --diploma-id ` ## 上下文传递表 | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `edu-contact school class-list` | `deptId` | task all-list 的 --biz-id | -| `edu-contact class students` | `userId` | task student-list 的 students.userId | -| `edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid | +| `dws edu-contact school class-list` | `deptId` | task all-list 的 --biz-id | +| `dws edu-contact class students` | `userId` | task student-list 的 students.userId | +| `dws edu-group class-group conversation-id` | `conversationId` | message summary-list 的 --cid | | `report by-teacher` | `schoolReportId` | report get/by-class/by-student-detail 的 --report-id | -| `edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id | +| `dws edu-contact family children` | `studentUserId`, `classId` | circle posts 的 --student-id, --class-id | +| `task publish-list` | `cardId` | card update/end/finish-info 的 --card-id | +| `task publish-list` | `taskCode` | card user-statistic 的 --task-code | +| `homework list-by-teacher` | `homeworkId` | homework get/delete/submit-statistics 的 --homework-id | +| `homework class-detail` | `hwContentDetailId` | homework submit / create-comment 的 --hw-content-detail-id | +| `notice list-by-teacher` | `noticeId` | notice get/confirm/confirm-status/delete 的 --notice-id | +| `diploma list-by-teacher` | `diplomaId` | diploma get/detail/statistics/delete 的 --diploma-id | --- diff --git a/skills/multi/dingtalk-misc/references/edu-familygroup.md b/skills/multi/dingtalk-misc/references/edu-familygroup.md index 82ec8631..cf49b4ae 100644 --- a/skills/multi/dingtalk-misc/references/edu-familygroup.md +++ b/skills/multi/dingtalk-misc/references/edu-familygroup.md @@ -65,4 +65,4 @@ |------|-------------|------| | `manage create` | `orgId`, `cid` | invite-parent / add-child 的 --org-id | | `group list-children` | 孩子 staffId | toggle-app 的 --child-staff-id | -| `edu-contact family list` | 家长 uid | 所有 edu-familygroup 命令的 --uid | +| `dws edu-contact family parents` | 家长 uid | 所有 edu-familygroup 命令的 --uid | From cbbc9a9f90f91b43ffb9dd46858861c96414343c Mon Sep 17 00:00:00 2001 From: "muling.cs" Date: Mon, 24 Aug 2026 20:13:26 +0800 Subject: [PATCH 15/33] refresh token legacy_recovery --- internal/auth/oauth_provider.go | 33 ++++++++--------- internal/auth/refresh_failure_test.go | 53 +++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 22 deletions(-) diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index 219693ce..be9ae37b 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -905,22 +905,22 @@ func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, if selected == nil { return nil, refreshErr } - slog.Debug("auth.refresh.legacy_recovery.triggered", + logging.AuthDebug("auth.refresh.legacy_recovery.triggered", "corp_id", strings.TrimSpace(selected.CorpID), "user_id", strings.TrimSpace(selected.UserID), "refresh_error_code", exchangeErr.Code, ) legacy, loadErr := tokenLoadKeychain() if loadErr != nil { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr) + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr) return nil, refreshErr } if legacy == nil { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy") + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy") return nil, refreshErr } if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) { - slog.Debug("auth.refresh.legacy_recovery.failed", + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "candidate_mismatch", "legacy_corp_id", strings.TrimSpace(legacy.CorpID), "legacy_user_id", strings.TrimSpace(legacy.UserID), @@ -936,30 +936,30 @@ func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, } if recovered.IsAccessTokenValid() { if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err) + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err) return nil, refreshErr } - slog.Debug("auth.refresh.legacy_recovery.success", "via", "valid_access_token") + logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token") return &recovered, nil } if !recovered.IsRefreshTokenValid() { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired") + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired") return nil, refreshErr } if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token") + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token") return nil, refreshErr } if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err) + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err) return nil, refreshErr } refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered) if recoverErr != nil { - slog.Debug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr) + logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr) return nil, refreshErr } - slog.Debug("auth.refresh.legacy_recovery.success", "via", "refresh") + logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh") return refreshed, nil } @@ -974,9 +974,6 @@ func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *Tok } selectedUserID := strings.TrimSpace(selected.UserID) legacyUserID := strings.TrimSpace(legacy.UserID) - if selectedUserID == "" { - return legacyUserID == "" - } if legacyUserID != "" { return legacyUserID == selectedUserID } @@ -984,17 +981,17 @@ func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *Tok } func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool { - if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" { + if strings.TrimSpace(corpID) == "" { return false } cfg, err := tokenLoadProfiles(configDir) if err != nil || cfg == nil { - slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err) + logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err) return false } profiles := profilesForCorpID(cfg, corpID) if len(profiles) != 1 { - slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", + logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "multi_profile", "corp_id", strings.TrimSpace(corpID), "profile_count", len(profiles), @@ -1009,7 +1006,7 @@ func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID strin if profile != nil { profileUserID = strings.TrimSpace(profile.UserID) } - slog.Debug("auth.refresh.legacy_recovery.blank_user_rejected", + logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "identity_mismatch", "selected_user_id", strings.TrimSpace(userID), "profile_user_id", profileUserID, diff --git a/internal/auth/refresh_failure_test.go b/internal/auth/refresh_failure_test.go index 9db8bf91..62bc7ab7 100644 --- a/internal/auth/refresh_failure_test.go +++ b/internal/auth/refresh_failure_test.go @@ -293,6 +293,51 @@ func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountC } } +func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) { + selected := &TokenData{ + AccessToken: "expired-identity-access", + RefreshToken: "rejected-identity-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-v1039-blank-selected", + Source: "mcp", + } + legacy := &TokenData{ + AccessToken: "valid-legacy-global-access", + RefreshToken: "valid-legacy-global-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: selected.CorpID, + Source: "mcp", + } + rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"} + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil }) + testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection }) + testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil }) + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{ + {Name: "Blank A", CorpID: selected.CorpID, UserID: ""}, + {Name: "Blank B", CorpID: selected.CorpID, UserID: ""}, + }}, nil + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound }) + saved := false + testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { + saved = true + return nil + }) + + _, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background()) + if !errors.Is(err, rejection) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if saved { + t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization") + } +} + func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) { selected := &TokenData{ AccessToken: "expired-identity-access", @@ -711,8 +756,11 @@ func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing } blankSelected := &TokenData{CorpID: selected.CorpID} + testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { + return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil + }) if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) { - t.Fatal("both blank user IDs should match") + t.Fatal("both blank user IDs should match only through the single-profile guard") } if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) { t.Fatal("blank selected with non-blank legacy accepted") @@ -721,7 +769,4 @@ func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) { t.Fatal("blank corp accepted by single-profile check") } - if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selected.CorpID, "") { - t.Fatal("blank user accepted by single-profile check") - } } From 66061bb0b00c2a684ba6008d0a57b34c90bf2834 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E8=8D=B7?= Date: Mon, 24 Aug 2026 20:23:21 +0800 Subject: [PATCH 16/33] =?UTF-8?q?fix(college-contact):=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E5=8F=AF=E5=A4=8D=E5=88=B6=E7=A0=B4=E5=9D=8F=E6=80=A7?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B=E4=B8=AD=E7=9A=84=E7=A1=AE=E8=AE=A4=E7=BB=95?= =?UTF-8?q?=E8=BF=87=E5=8F=82=E6=95=B0=E5=B9=B6=E8=A1=A5=E9=BD=90=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=E7=A4=BA=E4=BE=8B=E5=BF=85=E5=A1=AB=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR P1-1:15 个 user_required 叶子的 Cobra Example 携带 -y/--yes, Agent 或用户直接复制即绕过本 PR 实现的用户确认门。改为只保留 --dry-run 预览示例(含 -f json 变体),确认参数由调用方在取得 用户明确确认后自行追加。 CR P1-2:create-group-rule 的 Contract.Selection.Examples 缺少必填 --dept-type,示例实际执行会在参数校验阶段失败。同类缺陷在本文件另有 6 处(dept create / employee add / alumni list / alumni add-alumnus / alumni update-alumnus / graduate batch-update-pending),一并对齐到 各自 Cobra Example;另修正 dept update、dept batch-update-type 契约 示例中 dept-type 取值与 Cobra 示例不一致的问题(standard -> college)。 验证:5 个产品共 153 条 Contract.Selection.Examples 全部 --dry-run 可执行(153/153 ok);changed code coverage 100.0000%。 --- internal/helpers/college_contact.go | 63 +++++++++++------------------ 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/internal/helpers/college_contact.go b/internal/helpers/college_contact.go index 3b025f89..fab47b62 100644 --- a/internal/helpers/college_contact.go +++ b/internal/helpers/college_contact.go @@ -516,7 +516,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "创建部门", UseWhen: []string{"需要创建部门时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept create --super-id 12345 --stru-dept-id 12345"}, + Examples: []string{"dws college-contact dept create --super-id 100 --stru-dept-id 200 --name 计算机学院 --dept-type college --create-dept-group true"}, }, Parameters: []contract.ParamDecl{ {Name: "super-id", Property: "input.superId", Required: boolPtr(true)}, @@ -653,7 +653,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "更新部门", UseWhen: []string{"需要更新部门时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept update --dept-id 12345 --dept-type standard"}, + Examples: []string{"dws college-contact dept update --dept-id 12345 --dept-type college"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -688,8 +688,7 @@ func newCollegeContactCommand() *cobra.Command { 注意:删除操作不可逆。非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact dept delete --dept-id 12345 --dry-run - dws college-contact dept delete --dept-id 12345 -y - dws college-contact dept delete --dept-id 12345 -y -f json`, + dws college-contact dept delete --dept-id 12345 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { deptIDRaw, _ := cmd.Flags().GetString("dept-id") deptIDRaw = strings.TrimSpace(deptIDRaw) @@ -810,7 +809,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "批量修改部门类型", UseWhen: []string{"需要批量修改部门类型时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact dept batch-update-type --dept-ids 12345 --target-dept-type standard"}, + Examples: []string{"dws college-contact dept batch-update-type --dept-ids 12345 --target-dept-type college"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-ids", Property: "input.deptIds", Required: boolPtr(true)}, @@ -1897,7 +1896,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "添加员工", UseWhen: []string{"需要添加员工时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact employee add --emp-type standard --main-dept-id 12345"}, + Examples: []string{"dws college-contact employee add --emp-type college_student --main-dept-id 100 --exclusive-account true --name 张三 --mobile 13800138000"}, }, Parameters: []contract.ParamDecl{ {Name: "emp-type", Property: "input.empType", Required: boolPtr(true)}, @@ -1962,8 +1961,7 @@ func newCollegeContactCommand() *cobra.Command { 注意:移除操作不可逆。非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact employee remove --staff-ids S12345,S12346 --dry-run - dws college-contact employee remove --staff-ids S12345 -y - dws college-contact employee remove --staff-ids S12345,S12346 -y -f json`, + dws college-contact employee remove --staff-ids S12345,S12346 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { input := map[string]any{} @@ -2859,7 +2857,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "查询校友列表", UseWhen: []string{"需要查询校友列表时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni list --alumni-dept-id 12345 --order-field test"}, + Examples: []string{"dws college-contact alumni list --alumni-dept-id 12345 --order-field dept_entry --ordering asc"}, }, Parameters: []contract.ParamDecl{ {Name: "alumni-dept-id", Property: "input.alumniDeptId", Required: boolPtr(true)}, @@ -3297,8 +3295,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact alumni delete-dept --alumni-dept-id 12345 --dry-run - dws college-contact alumni delete-dept --alumni-dept-id 12345 -y - dws college-contact alumni delete-dept --alumni-dept-id 12345 -y -f json`, + dws college-contact alumni delete-dept --alumni-dept-id 12345 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) @@ -3524,7 +3521,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "添加校友", UseWhen: []string{"需要添加校友时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni add-alumnus --dept-ids 12345"}, + Examples: []string{"dws college-contact alumni add-alumnus --name 张三 --mobile 13800138000 --dept-ids 12345,67890"}, }, Parameters: []contract.ParamDecl{ {Name: "name", Property: "input.name", Required: boolPtr(false)}, @@ -3641,7 +3638,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "更新校友信息", UseWhen: []string{"需要更新校友信息时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact alumni update-alumnus --dept-ids 12345"}, + Examples: []string{"dws college-contact alumni update-alumnus --staff-id staff001 --name 张三 --dept-ids 12345,67890"}, }, Parameters: []contract.ParamDecl{ {Name: "staff-id", Property: "input.staffId", Required: boolPtr(false)}, @@ -3676,8 +3673,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact alumni remove-alumnus --staff-id staff001 --alumni-dept-id 12345 --dry-run - dws college-contact alumni remove-alumnus --staff-id staff001 --alumni-dept-id 12345 -y - dws college-contact alumni remove-alumnus --staff-id staff001 --alumni-dept-id 12345 -y -f json`, + dws college-contact alumni remove-alumnus --staff-id staff001 --alumni-dept-id 12345 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { staffId, _ := cmd.Flags().GetString("staff-id") staffId = strings.TrimSpace(staffId) @@ -3752,8 +3748,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact alumni cancel-invite --alumni-dept-id 12345 --staff-ids staff001,staff002 --dry-run - dws college-contact alumni cancel-invite --alumni-dept-id 12345 --staff-ids staff001 -y - dws college-contact alumni cancel-invite --alumni-dept-id 12345 --staff-ids staff001 -y -f json`, + dws college-contact alumni cancel-invite --alumni-dept-id 12345 --staff-ids staff001,staff002 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) @@ -3899,8 +3894,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact alumni disband-group --alumni-dept-id 12345 --dry-run - dws college-contact alumni disband-group --alumni-dept-id 12345 -y - dws college-contact alumni disband-group --alumni-dept-id 12345 -y -f json`, + dws college-contact alumni disband-group --alumni-dept-id 12345 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { alumniDeptIdRaw, _ := cmd.Flags().GetString("alumni-dept-id") alumniDeptIdRaw = strings.TrimSpace(alumniDeptIdRaw) @@ -4750,8 +4744,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact graduate commit-graduate --graduate-dept-ids 12345,12346 --graduate-year 2026 --dry-run - dws college-contact graduate commit-graduate --graduate-dept-ids 12345,12346 --graduate-year 2026 -y - dws college-contact graduate commit-graduate --graduate-dept-ids 12345,12346 --graduate-year 2026 --request-no xxx123 -y -f json`, + dws college-contact graduate commit-graduate --graduate-dept-ids 12345,12346 --graduate-year 2026 --request-no xxx123 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { raw, _ := cmd.Flags().GetString("graduate-dept-ids") raw = strings.TrimSpace(raw) @@ -4851,8 +4844,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact graduate all-graduate --graduate-year 2026 --dry-run - dws college-contact graduate all-graduate --graduate-year 2026 -y - dws college-contact graduate all-graduate --graduate-year 2026 --request-no xxx123 -y -f json`, + dws college-contact graduate all-graduate --graduate-year 2026 --request-no xxx123 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { graduateYearRaw, _ := cmd.Flags().GetString("graduate-year") graduateYearRaw = strings.TrimSpace(graduateYearRaw) @@ -4924,8 +4916,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact graduate batch-graduate --dept-id 12345 --staff-ids staff001,staff002 --dry-run - dws college-contact graduate batch-graduate --dept-id 12345 --staff-ids staff001,staff002 -y - dws college-contact graduate batch-graduate --dept-id 12345 --staff-ids staff001,staff002 -y -f json`, + dws college-contact graduate batch-graduate --dept-id 12345 --staff-ids staff001,staff002 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { deptIdRaw, _ := cmd.Flags().GetString("dept-id") deptIdRaw = strings.TrimSpace(deptIdRaw) @@ -5011,8 +5002,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact graduate delete-and-graduate --dept-id 12345 --staff-ids staff001,staff002 --dry-run - dws college-contact graduate delete-and-graduate --dept-id 12345 --staff-ids staff001,staff002 -y - dws college-contact graduate delete-and-graduate --dept-id 12345 --staff-ids staff001,staff002 -y -f json`, + dws college-contact graduate delete-and-graduate --dept-id 12345 --staff-ids staff001,staff002 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { deptIdRaw, _ := cmd.Flags().GetString("dept-id") deptIdRaw = strings.TrimSpace(deptIdRaw) @@ -5098,8 +5088,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact graduate batch-delete-pending --dept-id 12345 --staff-ids staff001,staff002 --dry-run - dws college-contact graduate batch-delete-pending --dept-id 12345 --staff-ids staff001,staff002 -y - dws college-contact graduate batch-delete-pending --dept-id 12345 --staff-ids staff001,staff002 -y -f json`, + dws college-contact graduate batch-delete-pending --dept-id 12345 --staff-ids staff001,staff002 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { deptIdRaw, _ := cmd.Flags().GetString("dept-id") deptIdRaw = strings.TrimSpace(deptIdRaw) @@ -5186,8 +5175,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact graduate batch-update-pending --dept-id 12345 --staff-ids staff001,staff002 --graduate-year 2026 --dry-run - dws college-contact graduate batch-update-pending --dept-id 12345 --staff-ids staff001,staff002 --graduate-year 2026 -y - dws college-contact graduate batch-update-pending --dept-id 12345 --staff-ids staff001,staff002 --graduate-year 2026 -y -f json`, + dws college-contact graduate batch-update-pending --dept-id 12345 --staff-ids staff001,staff002 --graduate-year 2026 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { deptIdRaw, _ := cmd.Flags().GetString("dept-id") deptIdRaw = strings.TrimSpace(deptIdRaw) @@ -5258,7 +5246,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "批量更新待毕业学生", UseWhen: []string{"需要批量更新待毕业学生时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact graduate batch-update-pending --dept-id 12345 --staff-ids 12345"}, + Examples: []string{"dws college-contact graduate batch-update-pending --dept-id 12345 --staff-ids staff001,staff002 --graduate-year 2026"}, }, Parameters: []contract.ParamDecl{ {Name: "dept-id", Property: "input.deptId", Required: boolPtr(true)}, @@ -5288,8 +5276,7 @@ func newCollegeContactCommand() *cobra.Command { 非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact graduate commit-restore --graduate-dept-ids 12345,12346 --dry-run - dws college-contact graduate commit-restore --graduate-dept-ids 12345,12346 -y - dws college-contact graduate commit-restore --graduate-dept-ids 12345,12346 --request-no xxx123 -y -f json`, + dws college-contact graduate commit-restore --graduate-dept-ids 12345,12346 --request-no xxx123 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { raw, _ := cmd.Flags().GetString("graduate-dept-ids") raw = strings.TrimSpace(raw) @@ -5627,7 +5614,7 @@ func newCollegeContactCommand() *cobra.Command { AgentSummary: "创建规则", UseWhen: []string{"需要创建规则时"}, AvoidWhen: []string{"与高校通讯录无关的操作"}, - Examples: []string{"dws college-contact group create-group-rule --name 测试 --tag-code test"}, + Examples: []string{"dws college-contact group create-group-rule --name 毕业生分组 --tag-code T001 --dept-type college"}, }, Parameters: []contract.ParamDecl{ {Name: "name", Property: "input.name", Required: boolPtr(true)}, @@ -5653,8 +5640,7 @@ func newCollegeContactCommand() *cobra.Command { 注意:删除操作不可逆。非 --dry-run 预览时必须显式传入 --yes 才会真实执行, 未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact group delete-group-rule --rule-id 1 --dry-run - dws college-contact group delete-group-rule --rule-id 1 -y - dws college-contact group delete-group-rule --rule-id 1 -y -f json`, + dws college-contact group delete-group-rule --rule-id 1 --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { ruleIDRaw, _ := cmd.Flags().GetString("rule-id") ruleIDRaw = strings.TrimSpace(ruleIDRaw) @@ -5886,8 +5872,7 @@ func newCollegeContactCommand() *cobra.Command { 注意:执行会对成员产生批量变更,影响范围大。非 --dry-run 预览时必须显式传入 --yes 才会真实执行,未传 --yes 直接拒绝;请在向用户展示操作摘要并获得确认后再追加 --yes。`, Example: ` dws college-contact group execute-group-rule --dry-run - dws college-contact group execute-group-rule -y - dws college-contact group execute-group-rule -y -f json`, + dws college-contact group execute-group-rule --dry-run -f json`, RunE: func(cmd *cobra.Command, args []string) error { return callMCPToolOnServer("college-contact", "execute_group_rule", map[string]any{ From aa265acd508c55fcc29445eec85acb708061900a Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 24 Aug 2026 21:46:54 +0800 Subject: [PATCH 17/33] chore(release): prepare v1.0.60-beta.2 --- .../1056-drive-permission-get-setting.md | 0 .../1082-sheet-whiteboard-shortcuts.md | 0 .../1083-aisearch-contact-live-shortcuts.md | 0 .../1085-permission-error-guidance.md | 0 .../1085-permission-member-pagination.md | 0 .../agoal-scorecard-search-entities.md | 0 .../aitable-datasource-shortcuts.md | 0 .../doc-read-public-and-history-version.md | 0 .../edu-college-vendor-extensions.md | 0 .../legacy-global-slot-recovery.md | 0 .../oa-approval-attachment-upload.md | 0 .../sheet-revision-changesets.md | 0 CHANGELOG.md | 78 +++++++++++++++++++ 13 files changed, 78 insertions(+) rename .changes/{ => released/1.0.60-beta.2}/1056-drive-permission-get-setting.md (100%) rename .changes/{ => released/1.0.60-beta.2}/1082-sheet-whiteboard-shortcuts.md (100%) rename .changes/{ => released/1.0.60-beta.2}/1083-aisearch-contact-live-shortcuts.md (100%) rename .changes/{ => released/1.0.60-beta.2}/1085-permission-error-guidance.md (100%) rename .changes/{ => released/1.0.60-beta.2}/1085-permission-member-pagination.md (100%) rename .changes/{ => released/1.0.60-beta.2}/agoal-scorecard-search-entities.md (100%) rename .changes/{ => released/1.0.60-beta.2}/aitable-datasource-shortcuts.md (100%) rename .changes/{ => released/1.0.60-beta.2}/doc-read-public-and-history-version.md (100%) rename .changes/{ => released/1.0.60-beta.2}/edu-college-vendor-extensions.md (100%) rename .changes/{ => released/1.0.60-beta.2}/legacy-global-slot-recovery.md (100%) rename .changes/{ => released/1.0.60-beta.2}/oa-approval-attachment-upload.md (100%) rename .changes/{ => released/1.0.60-beta.2}/sheet-revision-changesets.md (100%) diff --git a/.changes/1056-drive-permission-get-setting.md b/.changes/released/1.0.60-beta.2/1056-drive-permission-get-setting.md similarity index 100% rename from .changes/1056-drive-permission-get-setting.md rename to .changes/released/1.0.60-beta.2/1056-drive-permission-get-setting.md diff --git a/.changes/1082-sheet-whiteboard-shortcuts.md b/.changes/released/1.0.60-beta.2/1082-sheet-whiteboard-shortcuts.md similarity index 100% rename from .changes/1082-sheet-whiteboard-shortcuts.md rename to .changes/released/1.0.60-beta.2/1082-sheet-whiteboard-shortcuts.md diff --git a/.changes/1083-aisearch-contact-live-shortcuts.md b/.changes/released/1.0.60-beta.2/1083-aisearch-contact-live-shortcuts.md similarity index 100% rename from .changes/1083-aisearch-contact-live-shortcuts.md rename to .changes/released/1.0.60-beta.2/1083-aisearch-contact-live-shortcuts.md diff --git a/.changes/1085-permission-error-guidance.md b/.changes/released/1.0.60-beta.2/1085-permission-error-guidance.md similarity index 100% rename from .changes/1085-permission-error-guidance.md rename to .changes/released/1.0.60-beta.2/1085-permission-error-guidance.md diff --git a/.changes/1085-permission-member-pagination.md b/.changes/released/1.0.60-beta.2/1085-permission-member-pagination.md similarity index 100% rename from .changes/1085-permission-member-pagination.md rename to .changes/released/1.0.60-beta.2/1085-permission-member-pagination.md diff --git a/.changes/agoal-scorecard-search-entities.md b/.changes/released/1.0.60-beta.2/agoal-scorecard-search-entities.md similarity index 100% rename from .changes/agoal-scorecard-search-entities.md rename to .changes/released/1.0.60-beta.2/agoal-scorecard-search-entities.md diff --git a/.changes/aitable-datasource-shortcuts.md b/.changes/released/1.0.60-beta.2/aitable-datasource-shortcuts.md similarity index 100% rename from .changes/aitable-datasource-shortcuts.md rename to .changes/released/1.0.60-beta.2/aitable-datasource-shortcuts.md diff --git a/.changes/doc-read-public-and-history-version.md b/.changes/released/1.0.60-beta.2/doc-read-public-and-history-version.md similarity index 100% rename from .changes/doc-read-public-and-history-version.md rename to .changes/released/1.0.60-beta.2/doc-read-public-and-history-version.md diff --git a/.changes/edu-college-vendor-extensions.md b/.changes/released/1.0.60-beta.2/edu-college-vendor-extensions.md similarity index 100% rename from .changes/edu-college-vendor-extensions.md rename to .changes/released/1.0.60-beta.2/edu-college-vendor-extensions.md diff --git a/.changes/legacy-global-slot-recovery.md b/.changes/released/1.0.60-beta.2/legacy-global-slot-recovery.md similarity index 100% rename from .changes/legacy-global-slot-recovery.md rename to .changes/released/1.0.60-beta.2/legacy-global-slot-recovery.md diff --git a/.changes/oa-approval-attachment-upload.md b/.changes/released/1.0.60-beta.2/oa-approval-attachment-upload.md similarity index 100% rename from .changes/oa-approval-attachment-upload.md rename to .changes/released/1.0.60-beta.2/oa-approval-attachment-upload.md diff --git a/.changes/sheet-revision-changesets.md b/.changes/released/1.0.60-beta.2/sheet-revision-changesets.md similarity index 100% rename from .changes/sheet-revision-changesets.md rename to .changes/released/1.0.60-beta.2/sheet-revision-changesets.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 16dddbec..3ef0a3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,84 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.60-beta.2] - 2026-08-24 + +### Added + +- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node ` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call. + +- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification. +- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads. + +- **Permission and member list pagination** (#1085) — `drive/doc permission + list` and `wiki member list` now accept `--next-token` to follow the + server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and + map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults + 200` path; `permission add/update/remove` and `wiki member add/update/remove` + additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG + grantee types. The optional `--notify` defaults to `false` and is omitted from + the server request unless passed explicitly, so member grants no longer notify + recipients by default. These commands also declare cursor pagination + (`next-token`) in the Agent schema contract, mirroring the internal CLI parity + change. Because a single batch remove can revoke access for up to 30 + USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role + groups can indirectly affect many more users — `drive/doc permission + remove` and `wiki member remove` now declare + `confirmation=user_required` and gate the actual tool call behind user + confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their + confirmation-gate failure now also passes through verbatim instead of being + reclassified as a permission-denied or unclassified error. + +- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination. + +- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide. + +- **Doc public-link and historical-version reads** — `dws doc read` forwards + the reviewed `password` (internet-public documents with password protection) + and `historyVersion` (read content as of a listed historical version; `0` + denotes the document's initial version) parameters on the markdown, JSONML, + and scope read paths via `--password` / `--version`; `dws doc +fetch` gains + `--password` and `--version` with the same `historyVersion` forwarding, while + `--revision` stays rejected with explicit guidance: revision is the document + edit revision returned by JSONML reads for `+update --expected-revision` + conditional writes, not a historical version number. + +- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`. + +- **OA approval attachment upload** — `dws oa approval attachment upload --file ` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted. + +- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets. + +### Changed + +- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts. + +- **Permission error guidance and error rendering** (#1085) — + permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code + instead of a generic business-error rendering; document/wiki-specific errors + (the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`, + or the role-threshold wording like + “需要您具备 MANAGER 及以上角色”) carry apply-permission guidance + (`dws drive permission apply-info` / `dws drive permission apply`), while + permission failures carrying only generic code names (`FORBIDDEN`, + `NO_PERMISSION` — also returned by attendance and event-subscription tools) + or other products' wording keep their product-specific or + product-neutral suggestion instead of a misleading document-permission hint; + member-validation failures such as + “用户不存在/不属于当前组织” are classified as tool errors with a + `--members`-with-`corpId` suggestion instead of a misleading + resource-not-found error; business error output now surfaces the backend + message with `code`/`logId` appended for traceability; and the + `update_permission` / `remove_permission` / `update_member` / + `remove_member` tools — whose servers return a literal `null` on successful + no-payload writes — now render `{}` so downstream JSON consumers do not fail + parsing `null`; other tools keep raw `null` output unchanged. + +### Fixed + +- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations. + + ## [1.0.60-beta.1] - 2026-08-21 ### Changed From 0fae9dd8b337b76ad28c29b404b4fa1831646062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Mon, 24 Aug 2026 22:01:10 +0800 Subject: [PATCH 18/33] feat(cli): govern six product parameter aliases --- .../param_alias_payload_equivalence_test.go | 157 +++-- internal/cli/param_aliases_generated.go | 608 +++++++++++++++++- internal/cli/param_concepts.json | 290 ++++++++- 3 files changed, 975 insertions(+), 80 deletions(-) diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 9b2a74ce..30961b40 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -148,7 +148,7 @@ var paramAliasCompleteCommands = map[string][]string{ "contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"}, "contact +search-user": {"contact", "+search-user", "--query", "Fixture User"}, "contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"}, - "contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"}, + "contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"}, "dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"}, "devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"}, "ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"}, @@ -237,52 +237,106 @@ var paramAliasCompleteCommands = map[string][]string{ // param_concepts.json: inactive candidate templates are ignored, while every // command becomes mandatory as soon as one of its reviewed aliases is active. var paramAliasCandidateCompleteCommands = map[string][]string{ - "attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"}, - "attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"}, - "attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"}, - "attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"}, - "attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"}, - "attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"}, - "attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"}, - "attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"}, - "attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"}, - "attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"}, - "attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"}, - "attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"}, - "attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"}, - "attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"}, - "attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"}, - "ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"}, - "ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"}, - "ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"}, - "mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"}, - "mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"}, - "mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"}, - "mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"}, - "mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"}, - "mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"}, - "mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"}, - "mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"}, - "mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"}, - "mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"}, - "mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"}, - "mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"}, - "markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"}, - "markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"}, - "markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"}, - "markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"}, - "markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"}, - "oa +list-cc": {"oa", "+list-cc", "--page", "2"}, - "oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"}, - "oa +list-forms": {"oa", "+list-forms", "--cursor", "2"}, - "oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"}, - "oa +list-submitted": {"oa", "+list-submitted", "--page", "2"}, - "oa +my-initiated": {"oa", "+my-initiated", "--page", "2"}, - "report +outbox-list": {"report", "+outbox-list", "--size", "7"}, - "report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"}, - "report +template-search": {"report", "+template-search", "--query", "fixture"}, - "sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"}, - "sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"}, + "aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"}, + "aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"}, + "aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"}, + "aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"}, + "aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"}, + "contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"}, + "contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"}, + "contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"}, + "contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"}, + "contact +lookup": {"contact", "+lookup", "--name", "Fixture User"}, + "contact +org": {"contact", "+org", "--name", "Fixture User"}, + "contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"}, + "contact +team": {"contact", "+team", "--name", "Fixture User"}, + "contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"}, + "contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"}, + "contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"}, + "contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"}, + "contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"}, + "contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"}, + "contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"}, + "contact label get": {"contact", "label", "get", "--names", "Fixture Role"}, + "contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"}, + "contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"}, + "contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"}, + "contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`}, + "contact user search": {"contact", "user", "search", "--query", "Fixture User"}, + "contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"}, + "contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"}, + "contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"}, + "contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"}, + "devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"}, + "hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"}, + "hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"}, + "hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"}, + "hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"}, + "hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"}, + "hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"}, + "hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"}, + "hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`}, + "hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"}, + "hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"}, + "hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"}, + "hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"}, + "hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"}, + "hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"}, + "hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`}, + "hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"}, + "hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"}, + "hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"}, + "hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"}, + "hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"}, + "pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"}, + "pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"}, + "pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"}, + "attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"}, + "attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"}, + "attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"}, + "attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"}, + "attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"}, + "attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"}, + "attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"}, + "attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"}, + "attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"}, + "attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"}, + "attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"}, + "attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"}, + "attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"}, + "attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"}, + "attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"}, + "ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"}, + "ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"}, + "ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"}, + "mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"}, + "mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"}, + "mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"}, + "mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"}, + "mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"}, + "mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"}, + "mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"}, + "mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"}, + "mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"}, + "mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"}, + "mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"}, + "mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"}, + "markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"}, + "markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"}, + "markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"}, + "markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"}, + "markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"}, + "oa +list-cc": {"oa", "+list-cc", "--page", "2"}, + "oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"}, + "oa +list-forms": {"oa", "+list-forms", "--cursor", "2"}, + "oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"}, + "oa +list-submitted": {"oa", "+list-submitted", "--page", "2"}, + "oa +my-initiated": {"oa", "+my-initiated", "--page", "2"}, + "report +outbox-list": {"report", "+outbox-list", "--size", "7"}, + "report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"}, + "report +template-search": {"report", "+template-search", "--query", "fixture"}, + "sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"}, + "sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"}, "minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"}, "minutes +latest": {"minutes", "+latest", "--keyword", "fixture"}, @@ -1030,14 +1084,14 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string) } } -// TestCrossPlatformCoverageEightProductTemplatedParamAliasesCannotBypassConfirmation +// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation // exercises every distinct reviewed mutating complete-command template in the -// eight-product Shortcut expansion. The fixture gate already proves every +// reviewed product expansions. The fixture gate already proves every // alias resolves through PreParse; this gate removes the confirmation flag // from one active alias invocation per distinct template and requires the // runtime boundary to stop it before the first transport call. An explicit // --dry-run is a reviewed preview path and must not carry a bypass flag. -func TestCrossPlatformCoverageEightProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) { +func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) { concepts, err := cli.LoadParamConcepts() if err != nil { t.Fatalf("LoadParamConcepts() error = %v", err) @@ -1051,7 +1105,8 @@ func TestCrossPlatformCoverageEightProductTemplatedParamAliasesCannotBypassConfi } product, _, _ := strings.Cut(fixture.Command, " ") switch product { - case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown": + case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown", + "aisearch", "contact", "live", "devdoc", "hrbrain", "pat": default: continue } diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index b27d1e99..fe2a7b65 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -10,6 +10,102 @@ package cli // generatedParamAliases is the per-command parameter-alias table reduced from // the reviewed concept dictionary. Each entry binds one runnable Cobra leaf. var generatedParamAliases = []ParamAliasEntry{ + { + CLIPath: "aisearch", + Aliases: map[string]string{ + "dimensions": "dimension", + "person-dimension": "dimension", + "person-name": "query", + "person-query": "query", + "search-by": "dimension", + "search-dimension": "dimension", + "search-term": "query", + }, + Blocked: []string{"action-type", "behavior-type", "category", "chat-scope", "department-id", "direction", "employee-number", "field", "job-number", "mobile", "open-dingtalk-id", "phone-number", "queries", "search-types", "time-range", "types", "user-id"}, + Ambiguous: []string{"department", "duty", "employee", "id", "phone", "position", "scope", "target"}, + }, + { + CLIPath: "aisearch +search-person", + Aliases: map[string]string{ + "dimension": "dimensions", + "keyword": "query", + "keywords": "query", + "person-dimension": "dimensions", + "person-name": "query", + "person-query": "query", + "q": "query", + "search-by": "dimensions", + "search-dimension": "dimensions", + "search-term": "query", + "search-word": "query", + }, + Blocked: []string{"action-type", "behavior-type", "category", "chat-scope", "department-id", "direction", "employee-number", "field", "ids", "job-number", "mobile", "name", "open-dingtalk-id", "phone-number", "position-id", "queries", "search-types", "subject", "text", "time-range", "title", "type", "types", "user-id"}, + Ambiguous: []string{"department", "duty", "employee", "id", "phone", "position", "scope", "target"}, + }, + { + CLIPath: "aisearch behavior", + Aliases: map[string]string{ + "action-type": "behavior-type", + "activity-type": "behavior-type", + "behavior-action": "behavior-type", + "chat-name": "chat-scope", + "content-queries": "queries", + "content-query": "queries", + "content-types": "types", + "conversation-name": "chat-scope", + "date-range": "time-range", + "group-name": "chat-scope", + "im-scope": "chat-scope", + "interaction-direction": "direction", + "interaction-flow": "direction", + "message-flow": "direction", + "period": "time-range", + "resource-types": "types", + "search-categories": "types", + "search-period": "time-range", + "search-terms": "queries", + "search-type-list": "types", + "time-window": "time-range", + "topic-queries": "queries", + "topics": "queries", + }, + Blocked: []string{"chat-id", "conversation-id", "created-at", "dimension", "dimensions", "document-id", "end", "file-id", "from", "group-id", "natural-language", "node-id", "open-conversation-id", "open-dingtalk-id", "question", "receiver", "sender", "since", "start", "to", "until", "update", "updated-at", "user-id", "write"}, + Ambiguous: []string{"action", "actor", "category", "chat", "conversation", "date", "destination", "group", "range", "scope", "source", "target", "time", "type"}, + }, + { + CLIPath: "aisearch enterprise", + Aliases: map[string]string{ + "content-queries": "queries", + "content-query": "queries", + "content-types": "types", + "date-range": "time-range", + "period": "time-range", + "resource-types": "types", + "search-categories": "types", + "search-period": "time-range", + "search-terms": "queries", + "search-type-list": "types", + "time-window": "time-range", + "topic-queries": "queries", + "topics": "queries", + }, + Blocked: []string{"action-type", "behavior-type", "chat-scope", "created-at", "dimension", "dimensions", "direction", "document-id", "end", "file-id", "from", "group-name", "node-id", "open-dingtalk-id", "receiver", "sender", "since", "start", "to", "until", "update", "updated-at", "user-id", "write"}, + Ambiguous: []string{"category", "date", "natural-language", "question", "range", "scope", "source", "time", "type"}, + }, + { + CLIPath: "aisearch person", + Aliases: map[string]string{ + "dimensions": "dimension", + "person-dimension": "dimension", + "person-name": "query", + "person-query": "query", + "search-by": "dimension", + "search-dimension": "dimension", + "search-term": "query", + }, + Blocked: []string{"action-type", "behavior-type", "category", "chat-scope", "department-id", "direction", "employee-number", "field", "ids", "job-number", "mobile", "open-dingtalk-id", "phone-number", "position-id", "queries", "search-types", "time-range", "types", "user-id"}, + Ambiguous: []string{"department", "duty", "employee", "id", "phone", "position", "scope", "target"}, + }, { CLIPath: "aitable +advperm-disable", Aliases: map[string]string{ @@ -4128,6 +4224,13 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "id", "name", "open-conversation-ids", "source", "src-conversation-id", "target"}, }, + { + CLIPath: "contact +by-mobile", + Aliases: map[string]string{ + "org-user-mobile": "mobile", + }, + Blocked: []string{"email", "login-id", "user-id"}, + }, { CLIPath: "contact +dept-members", Aliases: map[string]string{ @@ -4148,6 +4251,23 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"department-id", "dept", "dept-id", "name", "query"}, }, + { + CLIPath: "contact +list-followings", + Aliases: map[string]string{ + "open-dingtalk-id": "open-id", + "open-dingtalkid": "open-id", + }, + Blocked: []string{"email", "mobile", "name", "open-dingtalk-ids", "phone", "query", "staff-id", "user-id", "userid"}, + Ambiguous: []string{"id", "person", "user"}, + }, + { + CLIPath: "contact +list-role-members", + Aliases: map[string]string{ + "label-id": "id", + "role-id": "id", + }, + Blocked: []string{"dept", "dept-id", "ids", "names", "staff-id", "user-id"}, + }, { CLIPath: "contact +list-sub-depts", Aliases: map[string]string{ @@ -4159,6 +4279,28 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"department-ids", "dept-ids", "depts", "name", "query"}, }, + { + CLIPath: "contact +lookup", + Aliases: map[string]string{ + "employee-name": "name", + "flower-name": "name", + "person-name": "name", + "staff-name": "name", + "user-name": "name", + }, + Blocked: []string{"department-id", "dept", "dept-id", "org-name", "role-name"}, + }, + { + CLIPath: "contact +org", + Aliases: map[string]string{ + "employee-name": "name", + "flower-name": "name", + "person-name": "name", + "staff-name": "name", + "user-name": "name", + }, + Blocked: []string{"department-id", "dept", "dept-id", "org-name", "role-name"}, + }, { CLIPath: "contact +resolve-dept", Aliases: map[string]string{ @@ -4170,6 +4312,13 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"subject", "text", "title"}, }, + { + CLIPath: "contact +search-mobile", + Aliases: map[string]string{ + "org-user-mobile": "mobile", + }, + Blocked: []string{"email", "login-id", "user-id"}, + }, { CLIPath: "contact +search-user", Aliases: map[string]string{ @@ -4180,6 +4329,65 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"name", "subject", "text", "title"}, }, + { + CLIPath: "contact +team", + Aliases: map[string]string{ + "employee-name": "name", + "flower-name": "name", + "person-name": "name", + "staff-name": "name", + "user-name": "name", + }, + Blocked: []string{"department-id", "dept", "dept-id", "org-name", "role-name"}, + }, + { + CLIPath: "contact account create", + Aliases: map[string]string{ + "account-login-id": "login-id", + "department-ids": "dept-ids", + "depts": "dept-ids", + "email-address": "email", + "employee-name": "org-user-name", + "login-account": "login-id", + "mail-address": "email", + "mobile": "org-user-mobile", + "organization-user-name": "org-user-name", + "staff-name": "org-user-name", + }, + Blocked: []string{"creator-username", "department-id", "department-memberships-json", "dept", "dept-id", "depts-json", "name", "nick", "org-name", "query", "staff-id", "user-id"}, + }, + { + CLIPath: "contact account update", + Aliases: map[string]string{ + "department-assignments-json": "depts", + "department-memberships-json": "depts", + "employee-name": "org-user-name", + "organization-user-name": "org-user-name", + "profile-image-file-id": "avatar-file-id", + "staff-id": "user-id", + "staff-name": "org-user-name", + "uid": "user-id", + "user": "user-id", + }, + Blocked: []string{"at-user-ids", "creator-username", "department-id", "department-ids", "dept", "dept-id", "dept-ids", "file", "name", "node-id", "org-name", "ownness-text", "parent", "parent-id", "to-user", "user-ids", "users"}, + Ambiguous: []string{"manager", "owner-id"}, + }, + { + CLIPath: "contact dept create", + Aliases: map[string]string{ + "new-parent-dept-id": "parent", + "parent-department-id": "parent", + "parent-dept-id": "parent", + }, + Blocked: []string{"department-ids", "dept", "dept-id", "dept-ids", "depts"}, + }, + { + CLIPath: "contact dept get-info", + Aliases: map[string]string{ + "department-id": "dept", + }, + Blocked: []string{"department", "department-ids", "depts", "parent", "parent-id"}, + }, { CLIPath: "contact dept list-children", Aliases: map[string]string{ @@ -4190,18 +4398,180 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"department-ids", "depts", "name", "query"}, }, + { + CLIPath: "contact dept list-members", + Aliases: map[string]string{ + "department-ids": "depts", + }, + Blocked: []string{"department-id", "dept", "name", "query"}, + }, + { + CLIPath: "contact dept search", + Aliases: map[string]string{ + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"subject", "text", "title"}, + }, + { + CLIPath: "contact dept update", + Aliases: map[string]string{ + "new-parent-dept-id": "parent", + "parent-department-id": "parent", + "parent-dept-id": "parent", + "target-department-id": "dept", + "target-dept-id": "dept", + }, + Blocked: []string{"department-ids", "depts"}, + Ambiguous: []string{"department", "target-id"}, + }, + { + CLIPath: "contact label get", + Aliases: map[string]string{ + "label-names": "names", + "role-names": "names", + }, + Blocked: []string{"dept-id", "id", "label-id", "role-id", "user-id"}, + }, + { + CLIPath: "contact label list-members", + Blocked: []string{"dept", "dept-id", "ids", "names", "staff-id", "user-id"}, + }, + { + CLIPath: "contact org create", + Aliases: map[string]string{ + "company-name": "org-name", + "corp-name": "org-name", + "creator-name": "creator-username", + "founder-name": "creator-username", + "organization-name": "org-name", + }, + Blocked: []string{"login-id", "user-id"}, + Ambiguous: []string{"name"}, + }, + { + CLIPath: "contact user dismission search", + Aliases: map[string]string{ + "begin": "start", + "current-page": "page", + "department-ids": "depts", + "dept-ids": "depts", + "employee-name": "name", + "end-date": "end", + "end-time": "end", + "flower-name": "name", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "page-no": "page", + "page-num": "page", + "page-size": "limit", + "per-page": "limit", + "person-name": "name", + "since": "start", + "size": "limit", + "staff-name": "name", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + "user-name": "name", + }, + Blocked: []string{"count", "cursor", "date", "department-id", "department-memberships-json", "dept", "dept-id", "dept-list-json", "offset", "page-index", "page-token", "query", "time"}, + }, + { + CLIPath: "contact user get", + Aliases: map[string]string{ + "member-user-ids": "ids", + "staff-ids": "ids", + "users": "ids", + }, + Blocked: []string{"at-user-ids", "staff-id", "uid", "user"}, + }, + { + CLIPath: "contact user invite", + Aliases: map[string]string{ + "department-assignments-json": "depts", + "department-memberships-json": "depts", + "employee-name": "org-user-name", + "mobile": "org-user-mobile", + "organization-user-name": "org-user-name", + "staff-name": "org-user-name", + }, + Blocked: []string{"creator-username", "department-id", "department-ids", "dept", "dept-id", "dept-ids", "email", "login-id", "nick", "org-name", "parent", "parent-id", "user-id"}, + }, { CLIPath: "contact user profile get", Aliases: map[string]string{ - "id": "staff-id", - "ids": "staff-id", - "uid": "staff-id", - "user": "staff-id", - "user-id": "staff-id", - "userid": "staff-id", + "field-code-list": "fields", + "field-codes": "fields", + "id": "staff-id", + "ids": "staff-id", + "profile-fields": "fields", + "roster-fields": "fields", + "uid": "staff-id", + "user": "staff-id", + "user-id": "staff-id", + "userid": "staff-id", + }, + Blocked: []string{"at-user-ids", "name", "output-fields", "staff-ids", "to-user", "user-ids", "users"}, + }, + { + CLIPath: "contact user search", + Aliases: map[string]string{ + "keywords": "query", + "q": "query", + "search-word": "query", + }, + Blocked: []string{"subject", "text", "title"}, + }, + { + CLIPath: "contact user search-mobile", + Aliases: map[string]string{ + "org-user-mobile": "mobile", + }, + Blocked: []string{"email", "login-id", "user-id"}, + }, + { + CLIPath: "contact user update", + Aliases: map[string]string{ + "department-assignments-json": "depts", + "department-memberships-json": "depts", + "employee-name": "org-user-name", + "organization-user-name": "org-user-name", + "staff-id": "user-id", + "staff-name": "org-user-name", + "uid": "user-id", + "user": "user-id", + }, + Blocked: []string{"at-user-ids", "creator-username", "department-id", "department-ids", "dept", "dept-id", "dept-ids", "name", "nick", "org-name", "parent", "parent-id", "to-user", "user-ids", "users"}, + Ambiguous: []string{"manager", "owner-id"}, + }, + { + CLIPath: "contact user update-ownness", + Aliases: map[string]string{ + "personal-status": "ownness-text", + "staff-id": "user-id", + "status": "ownness-text", + "status-text": "ownness-text", + "uid": "user-id", + "user": "user-id", }, Blocked: []string{"at-user-ids", "name", "to-user", "user-ids", "users"}, }, + { + CLIPath: "contact user update-self", + Aliases: map[string]string{ + "profile-image-file-id": "avatar-file-id", + }, + Blocked: []string{"file", "name", "node-id", "org-user-name", "ownness-text", "user-id"}, + }, { CLIPath: "dev app get", Aliases: map[string]string{ @@ -4213,18 +4583,26 @@ var generatedParamAliases = []ParamAliasEntry{ { CLIPath: "devdoc article search", Aliases: map[string]string{ - "current-page": "page", - "limit": "size", - "max-result": "size", - "max-results": "size", - "page-no": "page", - "page-num": "page", - "page-size": "size", - "per-page": "size", - "take": "size", - "top": "size", + "current-page": "page", + "keywords": "query", + "limit": "size", + "max-result": "size", + "max-results": "size", + "page-no": "page", + "page-num": "page", + "page-number": "page", + "page-size": "size", + "per-page": "size", + "q": "query", + "query-text": "query", + "results-per-page": "size", + "search-term": "query", + "search-word": "query", + "take": "size", + "top": "size", }, - Blocked: []string{"count", "cursor", "page-index", "page-token"}, + Blocked: []string{"context", "count", "cursor", "doc-id", "document-id", "error-code", "error-message", "file-id", "name", "next-cursor", "next-token", "node-id", "offset", "page-index", "page-token", "request-id", "space-id", "subject", "text", "title", "total", "trace-id", "workspace-id"}, + Ambiguous: []string{"id", "type"}, }, { CLIPath: "ding +list", @@ -6352,6 +6730,166 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"knowledge-base-id", "limit", "page-size", "part-size", "size", "space", "wiki-workspace-id", "workspace", "workspace-id"}, }, + { + CLIPath: "hrbrain profile career", + Aliases: map[string]string{ + "employee-no": "work-no", + "employee-number": "work-no", + "staff-no": "work-no", + }, + Blocked: []string{"employee-id", "end", "end-date", "job-level", "position", "staff-id", "staff-ids", "start", "start-date", "user-id", "work-nos", "year"}, + Ambiguous: []string{"date", "employee", "id", "name", "time", "user"}, + }, + { + CLIPath: "hrbrain profile labels", + Aliases: map[string]string{ + "all-labels": "all-label", + "employee-nos": "staff-ids", + "staff-nos": "staff-ids", + "work-nos": "staff-ids", + }, + Blocked: []string{"employee-id", "employee-ids", "employee-no", "staff-id", "staff-no", "user-id", "user-ids", "work-no"}, + Ambiguous: []string{"employee", "id", "label", "name", "tag", "user"}, + }, + { + CLIPath: "hrbrain profile metadata", + Aliases: map[string]string{ + "employee-no": "work-no", + "employee-number": "work-no", + "staff-no": "work-no", + }, + Blocked: []string{"data-queries", "employee-id", "model-code", "module", "section", "staff-id", "staff-ids", "user-id", "work-nos"}, + Ambiguous: []string{"employee", "field", "id", "name", "user"}, + }, + { + CLIPath: "hrbrain profile performance", + Aliases: map[string]string{ + "employee-no": "work-no", + "employee-number": "work-no", + "staff-no": "work-no", + }, + Blocked: []string{"cycle", "employee-id", "end", "end-date", "rating", "score", "staff-id", "staff-ids", "start", "start-date", "user-id", "work-nos", "year"}, + Ambiguous: []string{"date", "employee", "id", "level", "name", "time", "user"}, + }, + { + CLIPath: "hrbrain profile query", + Aliases: map[string]string{ + "employee-no": "work-no", + "employee-number": "work-no", + "module-queries": "data-queries", + "profile-queries": "data-queries", + "staff-no": "work-no", + }, + Blocked: []string{"data-query", "employee-id", "model-code", "module", "section", "staff-id", "staff-ids", "user-id", "work-nos"}, + Ambiguous: []string{"employee", "field", "id", "name", "query", "user"}, + }, + { + CLIPath: "hrbrain search employees", + Aliases: map[string]string{ + "current-page": "page", + "department-name": "dept-name", + "job-title": "position-name", + "keywords": "keyword", + "limit": "page-size", + "max-result": "page-size", + "max-results": "page-size", + "page-no": "page", + "page-num": "page", + "per-page": "page-size", + "position-level": "job-level", + "q": "keyword", + "query": "keyword", + "search-word": "keyword", + "size": "page-size", + "take": "page-size", + "talent-pool-code": "pool-code", + "top": "page-size", + }, + Blocked: []string{"count", "cursor", "data-queries", "department-id", "dept-id", "employee-id", "order-by", "origin-json", "page-index", "page-token", "pool-id", "staff-id", "staff-ids", "subject", "talent-pool-id", "text", "user-id", "work-no"}, + Ambiguous: []string{"code", "department", "grade", "id", "level", "name", "pool", "position", "title", "user"}, + }, + { + CLIPath: "hrbrain search employees-structured", + Aliases: map[string]string{ + "criteria-json": "origin-json", + "current-page": "page", + "filter-json": "origin-json", + "limit": "page-size", + "max-result": "page-size", + "max-results": "page-size", + "page-no": "page", + "page-num": "page", + "per-page": "page-size", + "result-fields-json": "fields", + "return-fields-json": "fields", + "search-json": "origin-json", + "size": "page-size", + "sort-by": "order-by", + "sort-fields": "order-by", + "take": "page-size", + "top": "page-size", + }, + Blocked: []string{"count", "cursor", "department-name", "dept-name", "field-name", "job-level", "keyword", "operator", "operators", "page-index", "page-token", "pool-code", "position-name", "query", "staff-ids", "work-no"}, + Ambiguous: []string{"criteria", "field", "filter", "id", "name", "order", "sort", "type"}, + }, + { + CLIPath: "hrbrain search fields", + Blocked: []string{"dept-name", "field-name", "job-level", "keyword", "operator", "operators", "order-by", "origin-json", "page", "page-size", "pool-code", "position-name", "query", "staff-ids", "work-no"}, + Ambiguous: []string{"criteria", "field", "filter", "id", "name", "type"}, + }, + { + CLIPath: "hrbrain talent-pool detail", + Aliases: map[string]string{ + "talent-pool-code": "pool-code", + }, + Blocked: []string{"creator", "employee-id", "keyword", "labels", "page", "page-size", "pool-id", "query", "staff-ids", "talent-pool-id", "work-no"}, + Ambiguous: []string{"code", "id", "name", "pool", "type"}, + }, + { + CLIPath: "hrbrain talent-pool employees", + Aliases: map[string]string{ + "current-page": "page", + "limit": "page-size", + "max-result": "page-size", + "max-results": "page-size", + "page-no": "page", + "page-num": "page", + "per-page": "page-size", + "size": "page-size", + "take": "page-size", + "talent-pool-code": "pool-code", + "top": "page-size", + }, + Blocked: []string{"count", "creator", "cursor", "dept-name", "employee-id", "job-level", "keyword", "labels", "page-index", "page-token", "pool-id", "position-name", "query", "staff-ids", "talent-pool-id", "work-no"}, + Ambiguous: []string{"code", "id", "name", "pool", "status"}, + }, + { + CLIPath: "hrbrain talent-pool list", + Aliases: map[string]string{ + "current-page": "page", + "keywords": "keyword", + "label-list": "labels", + "limit": "page-size", + "max-result": "page-size", + "max-results": "page-size", + "page-no": "page", + "page-num": "page", + "per-page": "page-size", + "q": "keyword", + "query": "keyword", + "search-word": "keyword", + "size": "page-size", + "take": "page-size", + "top": "page-size", + }, + Blocked: []string{"count", "cursor", "employee-id", "name", "page-index", "page-token", "pool-code", "pool-id", "staff-ids", "subject", "talent-pool-id", "text", "title", "user-id", "work-no"}, + Ambiguous: []string{"code", "creator-id", "id", "label-name", "pool", "type", "user"}, + }, + { + CLIPath: "live stream list", + Blocked: []string{"anchor-id", "conversation-id", "cover-url", "create", "creator-id", "cursor", "end", "end-live", "end-time", "from", "group-id", "keyword", "limit", "live-id", "offset", "order", "page", "page-size", "page-token", "playback", "query", "record", "room-id", "size", "sort", "start", "start-live", "start-time", "state", "status", "stream-id", "title", "to", "user-id"}, + Ambiguous: []string{"date", "id", "owner", "time", "type", "user"}, + }, { CLIPath: "mail +contact-list", Aliases: map[string]string{ @@ -7457,6 +7995,42 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"name", "subject", "text", "title"}, }, + { + CLIPath: "pat +browser-policy", + Aliases: map[string]string{ + "allow-browser": "enabled", + "browser-enabled": "enabled", + "open-browser": "enabled", + }, + Blocked: []string{"api-scope", "app-id", "app-key", "app-secret", "block-browser", "browser-disabled", "deny-browser", "disable-browser", "domain", "domains", "grant-mode", "grant-type", "oauth-scope", "permission", "permissions", "product", "products", "recommend", "recommended", "scope", "scopes", "session-id"}, + Ambiguous: []string{"agent", "agent-id", "browser", "mode", "policy", "state", "value"}, + }, + { + CLIPath: "pat browser-policy", + Aliases: map[string]string{ + "allow-browser": "enabled", + "browser-enabled": "enabled", + "open-browser": "enabled", + }, + Blocked: []string{"api-scope", "app-id", "app-key", "app-secret", "block-browser", "browser-disabled", "deny-browser", "disable-browser", "domain", "domains", "grant-mode", "grant-type", "oauth-scope", "permission", "permissions", "product", "products", "recommend", "recommended", "scope", "scopes", "session-id"}, + Ambiguous: []string{"agent", "agent-id", "browser", "mode", "policy", "state", "value"}, + }, + { + CLIPath: "pat chmod", + Aliases: map[string]string{ + "authorization-mode": "grant-type", + "authorization-session-id": "session-id", + "domain-code": "domain", + "domain-codes": "domains", + "grant-mode": "grant-type", + "pat-session-id": "session-id", + "product-code": "product", + "product-codes": "products", + "recommended": "recommend", + }, + Blocked: []string{"all", "all-scopes", "allow-browser", "api-scope", "app-id", "app-key", "app-secret", "browser-enabled", "browser-policy", "confirm", "confirmed", "disable-browser", "force", "oauth-scope", "open-browser", "permission", "permission-scope", "permission-scopes", "permissions", "scope", "scopes"}, + Ambiguous: []string{"action", "agent", "agent-id", "id", "mode", "resource", "service", "services", "session", "type"}, + }, { CLIPath: "report +inbox-list", Aliases: map[string]string{ diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 1bed0a84..e434a422 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -10,19 +10,19 @@ }, "concepts": { - "search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search","attendance +search-adjustment-rule","attendance +search-class","attendance +search-group","attendance +search-overtime-rule","mail +user-search","oa +list-cc","oa +list-executed","oa +list-pending","oa +list-submitted","oa +my-initiated","report +template-search"],"risk":"green"}, - "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search","attendance +check-result","attendance +search-adjustment-rule","attendance +search-class","attendance +search-group","attendance +search-overtime-rule","mail +contact-list","mail +find-mail-user","mail +recent-mail","mail +search-mail","mail +template-list","mail +thread-list","mail +unread-mail","mail +user-search","mail +triage","oa +list-cc","oa +list-forms","oa +list-pending","oa +list-submitted","oa +my-initiated","report +inbox-list","report +outbox-list"],"risk":"green"}, - "page_number": {"denotes":"one-based page number","canonical_hint":"page","members":["page","page-no","current-page","page-num"],"excludes":["cursor","page-index","page-size","page-token"],"commands":["devdoc article search","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list","attendance +search-adjustment-rule","attendance +search-class","attendance +search-group","attendance +search-overtime-rule","oa +list-cc","oa +list-executed","oa +list-pending","oa +list-submitted","oa +my-initiated"],"risk":"green"}, + "search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search","attendance +search-adjustment-rule","attendance +search-class","attendance +search-group","attendance +search-overtime-rule","mail +user-search","oa +list-cc","oa +list-executed","oa +list-pending","oa +list-submitted","oa +my-initiated","report +template-search","aisearch +search-person","contact dept search","contact user search","devdoc article search","hrbrain talent-pool list","hrbrain search employees"],"risk":"green"}, + "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search","attendance +check-result","attendance +search-adjustment-rule","attendance +search-class","attendance +search-group","attendance +search-overtime-rule","mail +contact-list","mail +find-mail-user","mail +recent-mail","mail +search-mail","mail +template-list","mail +thread-list","mail +unread-mail","mail +user-search","mail +triage","oa +list-cc","oa +list-forms","oa +list-pending","oa +list-submitted","oa +my-initiated","report +inbox-list","report +outbox-list","contact user dismission search","hrbrain talent-pool list","hrbrain talent-pool employees","hrbrain search employees","hrbrain search employees-structured"],"risk":"green"}, + "page_number": {"denotes":"one-based page number","canonical_hint":"page","members":["page","page-no","current-page","page-num"],"excludes":["cursor","page-index","page-size","page-token"],"commands":["devdoc article search","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list","attendance +search-adjustment-rule","attendance +search-class","attendance +search-group","attendance +search-overtime-rule","oa +list-cc","oa +list-executed","oa +list-pending","oa +list-submitted","oa +my-initiated","contact user dismission search","hrbrain talent-pool list","hrbrain talent-pool employees","hrbrain search employees","hrbrain search employees-structured"],"risk":"green"}, "page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event","minutes +detail","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","wiki +feed-list","wiki +node-search","wiki feed list","wiki node list","wiki node search","wiki space list","mail +contact-list","mail +template-list","mail +thread-list","mail +user-search","mail +find-mail-user","mail +recent-mail","mail +search-mail","mail +triage","mail +unread-mail","oa +list-forms"],"risk":"green"}, "content_text": {"denotes":"text body content","canonical_hint":"text","members":["text","content","body"],"excludes":["title","name"],"commands":["doc +checkpoint-update","doc +comment-create","doc +comment-reply","doc +comment-update","doc +create","doc +doc-append","doc block insert","doc block update","doc comment create","doc comment create-inline","doc comment reply","doc comment update","doc create","minutes +summary","minutes update summary","todo +comment","todo comment add","ding +send-personal","markdown create","markdown overwrite"],"risk":"green"}, - "time_start": {"denotes":"start time point with unchanged value format and unit","canonical_hint":"start","members":["start","start-time","start-date","from","from-date","begin","since","time-min","min-time"],"excludes":["date","time","end"],"commands":["calendar event list","chat message list-all","report list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared","attendance +check-record","attendance +check-result","attendance +get-checkin-record","attendance +get-leave-records","attendance +get-schedule","attendance +list-approve","attendance +query-report-data","mail +thread-list","oa +list-pending","report +inbox-list","report +outbox-list","report +report-latest"],"risk":"yellow"}, - "time_end": {"denotes":"end time point with unchanged value format and unit","canonical_hint":"end","members":["end","end-time","end-date","time-max","max-time"],"excludes":["date","time","start"],"commands":["calendar event list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared","attendance +check-record","attendance +check-result","attendance +get-checkin-record","attendance +get-leave-records","attendance +get-schedule","attendance +list-approve","attendance +query-report-data","mail +thread-list","oa +list-pending","report +inbox-list","report +outbox-list","report +report-latest"],"risk":"yellow"}, + "time_start": {"denotes":"start time point with unchanged value format and unit","canonical_hint":"start","members":["start","start-time","start-date","from","from-date","begin","since","time-min","min-time"],"excludes":["date","time","end"],"commands":["calendar event list","chat message list-all","report list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared","attendance +check-record","attendance +check-result","attendance +get-checkin-record","attendance +get-leave-records","attendance +get-schedule","attendance +list-approve","attendance +query-report-data","mail +thread-list","oa +list-pending","report +inbox-list","report +outbox-list","report +report-latest","contact user dismission search"],"risk":"yellow"}, + "time_end": {"denotes":"end time point with unchanged value format and unit","canonical_hint":"end","members":["end","end-time","end-date","time-max","max-time"],"excludes":["date","time","start"],"commands":["calendar event list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared","attendance +check-record","attendance +check-result","attendance +get-checkin-record","attendance +get-leave-records","attendance +get-schedule","attendance +list-approve","attendance +query-report-data","mail +thread-list","oa +list-pending","report +inbox-list","report +outbox-list","report +report-latest","contact user dismission search"],"risk":"yellow"}, "base_id": {"denotes":"multi-dimensional table Base id","canonical_hint":"base-id","members":["base","base-id","base-token"],"excludes":[],"commands":["aitable +advperm-disable","aitable +advperm-enable","aitable +attachment-put","aitable +attachment-remove","aitable +attachment-upload","aitable +base-copy","aitable +base-delete","aitable +base-get","aitable +base-get-primary-doc-id","aitable +base-schema-snapshot","aitable +base-update","aitable +chart-delete","aitable +chart-get","aitable +chart-share-get","aitable +chart-share-update","aitable +chart-update","aitable +dashboard-arrange","aitable +dashboard-delete","aitable +dashboard-get","aitable +dashboard-share-get","aitable +dashboard-share-update","aitable +dashboard-update","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-upload","aitable +list-tables","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +resolve-table","aitable +role-create","aitable +role-delete","aitable +role-get","aitable +role-list","aitable +role-update","aitable +section-create","aitable +section-delete","aitable +section-list-empty","aitable +section-list-nodes","aitable +section-move-node","aitable +section-rename","aitable +section-reorder","aitable +table-delete","aitable +table-get","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable +workflow-list","aitable advperm disable","aitable advperm enable","aitable advperm role-create","aitable advperm role-delete","aitable advperm role-get","aitable advperm role-list","aitable advperm role-update","aitable attachment upload","aitable base copy","aitable base delete","aitable base get","aitable base get-primary-doc-id","aitable base update","aitable chart create","aitable chart delete","aitable chart get","aitable chart share get","aitable chart share update","aitable chart update","aitable dashboard arrange","aitable dashboard create","aitable dashboard delete","aitable dashboard get","aitable dashboard share get","aitable dashboard share update","aitable dashboard update","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import upload","aitable info","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable section create","aitable section delete","aitable section list-empty","aitable section list-nodes","aitable section move-node","aitable section rename","aitable section reorder","aitable table create","aitable table delete","aitable table get","aitable table list","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow create","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow list","aitable workflow run","aitable workflow update"],"risk":"green"}, "table_id": {"denotes":"single AI Table data-table Table ID","canonical_hint":"table-id","members":["table","table-id"],"excludes":["table-ids","source-table-id","target-table-id","name"],"commands":["aitable +attachment-put","aitable +attachment-remove","aitable +base-get-primary-doc-id","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-data","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +table-delete","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable base get-primary-doc-id","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import data","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable table delete","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow run"],"risk":"green"}, "plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update","calendar +create","calendar +update","wiki +space-create","wiki space create"],"risk":"green"}, "workflow_id": {"denotes":"single AI Table workflow identifier; service flowId and CLI workflow-id carry the same value","canonical_hint":"workflow-id","members":["workflow-id","flow-id"],"excludes":["id","task-id"],"commands":["aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow run","aitable workflow update"],"risk":"green"}, "dept_id": {"denotes":"single department id","canonical_hint":"dept","members":["dept","dept-id","department","department-id","parent","parent-id"],"excludes":["depts","dept-ids","department-ids","name","query"],"commands":["contact +list-sub-depts","contact dept list-children"],"risk":"yellow"}, - "dept_ids": {"denotes":"department id list","canonical_hint":"dept-ids","members":["depts","dept-ids","department-ids"],"excludes":["dept","dept-id","department-id","name","query"],"commands":["contact +list-dept-members"],"risk":"yellow"}, + "dept_ids": {"denotes":"department id list","canonical_hint":"dept-ids","members":["depts","dept-ids","department-ids"],"excludes":["dept","dept-id","department-id","name","query"],"commands":["contact +list-dept-members","contact account create","contact dept list-members","contact user dismission search"],"risk":"yellow"}, "group_id": {"denotes":"single DingTalk numeric groupId","canonical_hint":"group-id","members":["group-id"],"excludes":["group","conversation-id","chat","chat-id","open-conversation-id","conversation-ids","open-conversation-ids","group-name","name","id"],"commands":["chat +chat-get-by-id","chat group get-by-group-id"],"risk":"yellow"}, "open_conversation_id": {"denotes":"single DingTalk openConversationId with unchanged value","canonical_hint":"conversation-id","members":["group","conversation-id","chat","chat-id","open-conversation-id"],"excludes":["group-id","group-ids","conversation-ids","open-conversation-ids","group-name","name","id","source","target","src-conversation-id","dest-conversation-id"],"commands":["chat +category-add-conversation","chat +category-remove-conversation","chat +chat-add-bot","chat +chat-audit-join","chat +chat-bots","chat +chat-dismiss","chat +chat-invite-url","chat +chat-members-get","chat +chat-mute","chat +chat-mute-member","chat +chat-quit","chat +chat-remove-bot","chat +chat-role-add","chat +chat-role-list","chat +chat-role-query-user","chat +chat-role-remove","chat +chat-role-remove-user","chat +chat-role-set-user","chat +chat-role-update","chat +chat-set-admin","chat +chat-set-history","chat +chat-transfer-owner","chat +chat-update-alias","chat +chat-update-icon","chat +chat-update-nick","chat +chat-update-settings","chat +conversation-clear-messages","chat +conversation-clear-red-point","chat +conversation-hide","chat +conversation-info","chat +conversation-mark-read","chat +conversation-mark-unread","chat +conversation-mute","chat +flag-cancel","chat +flag-create","chat +messages-add-emoji","chat +messages-add-text-emotion","chat +messages-list-pin","chat +messages-read-status","chat +messages-recall-by-bot","chat +messages-remove-emoji","chat +messages-remove-text-emotion","chat +messages-reply","chat +messages-resource-download","chat +messages-resource-url","chat +messages-send-by-bot","chat +messages-set-pin","chat +messages-set-top","chat +messages-unset-pin","chat +messages-unset-top","chat category add-conv","chat category remove-conv","chat chmod","chat clear-messages","chat clear-red-point","chat conversation-info","chat group audit-join-validation","chat group bots","chat group dismiss","chat group invite-url","chat group members","chat group members add","chat group members add-bot","chat group members list-by-ids","chat group members remove","chat group members remove-bot","chat group notice create","chat group notice edit","chat group notice get","chat group notice list","chat group quit","chat group rename","chat group set-admin","chat group set-history","chat group transfer-owner","chat group update-alias","chat group update-icon","chat group update-nick","chat group update-settings","chat group-mute","chat group-mute-member","chat group-role add","chat group-role list","chat group-role query-user","chat group-role remove","chat group-role remove-user","chat group-role set-user","chat group-role update","chat hide","chat mark-read","chat mark-unread","chat message add-favorite","chat message download-media","chat message list","chat message list-mentions","chat message list-pin-msg","chat message list-topic-replies","chat message read-status","chat message recall","chat message recall-by-bot","chat message remove-favorite","chat message reply","chat message search","chat message send","chat message send-by-bot","chat message send-card","chat message set-pin-msg","chat message set-top-msg","chat message unset-pin-msg","chat message unset-top-msg","chat mute-at-all","chat mute-red-envelope","chat set-top"],"risk":"yellow"}, "open_conversation_ids": {"denotes":"DingTalk openConversationId list with unchanged element values","canonical_hint":"conversation-ids","members":["conversation-ids","open-conversation-ids","groups"],"excludes":["group-id","group-ids","conversation-id","open-conversation-id","chat-id"],"commands":["chat message search-advanced"],"risk":"yellow"}, @@ -30,8 +30,8 @@ "open_message_id": {"denotes":"single DingTalk openMessageId with unchanged value","canonical_hint":"open-message-id","members":["msg-id","message-id","open-message-id"],"excludes":["msg-ids","message-ids","open-message-ids","ref-msg-id","src-msg-id","open-task-id","topic-id","resource-id"],"commands":["chat +conversation-mark-read","chat +flag-cancel","chat +flag-create","chat +messages-add-emoji","chat +messages-add-text-emotion","chat +messages-forward","chat +messages-read-status","chat +messages-remove-emoji","chat +messages-remove-text-emotion","chat +messages-resource-download","chat +messages-set-pin","chat +messages-set-top","chat +messages-unset-pin","chat +messages-unset-top","chat mark-read","chat message add-emoji","chat message add-favorite","chat message add-text-emotion","chat message download-media","chat message forward","chat message read-status","chat message recall","chat message remove-emoji","chat message remove-favorite","chat message remove-text-emotion","chat message set-pin-msg","chat message set-top-msg","chat message unset-pin-msg","chat message unset-top-msg"],"risk":"yellow"}, "open_message_ids": {"denotes":"DingTalk openMessageId list with unchanged element values","canonical_hint":"msg-ids","members":["msg-ids","message-ids","open-message-ids"],"excludes":["msg-id","message-id","open-message-id","ref-msg-id","src-msg-id"],"commands":["chat +flag-cancel","chat +flag-create","chat +messages-combine-forward","chat +messages-mget","chat message combine-forward","chat message list-by-ids","chat message list-emotion-replies"],"risk":"yellow"}, "referenced_open_message_id": {"denotes":"referenced DingTalk openMessageId in a reply","canonical_hint":"ref-msg-id","members":["ref-msg-id","ref-message-id"],"excludes":["msg-id","message-id","open-message-id","msg-ids","src-msg-id"],"commands":["chat +messages-reply","chat message reply"],"risk":"yellow"}, - "user_id": {"denotes":"single user id","canonical_hint":"user-id","members":["user","user-id","userid","uid","staff-id"],"excludes":["at-user-ids","to-user","users","user-ids","name"],"commands":["chat +chat-role-query-user","chat +chat-role-set-user","chat +messages-list-direct","chat chmod","chat conversation-info","chat group transfer-owner","chat group-role query-user","chat group-role remove-user","chat group-role set-user","chat message list","chat message send","contact user profile get","attendance +get-leave-records","attendance +get-self-setting","attendance +get-summary"],"risk":"yellow"}, - "user_ids": {"denotes":"user id list","canonical_hint":"user-ids","members":["users","user-ids"],"excludes":["user","user-id","userid","uid","staff-id","at-user-ids"],"commands":["attendance +check-result","attendance check result","chat +messages-batch-send-by-bot","chat group members remove","chat group set-admin","chat group-mute-member","chat message read-status","chat message search-advanced","chat message send-by-bot","calendar +freebusy","calendar +suggestion","minutes +share","minutes +unshare","minutes permission add","minutes permission remove","wiki +member-add","wiki +member-remove","wiki +member-update","attendance +check-record","attendance +get-checkin-record","attendance +get-schedule","attendance +list-approve","attendance +query-report-data"],"risk":"yellow"}, + "user_id": {"denotes":"single user id","canonical_hint":"user-id","members":["user","user-id","userid","uid","staff-id"],"excludes":["at-user-ids","to-user","users","user-ids","name"],"commands":["chat +chat-role-query-user","chat +chat-role-set-user","chat +messages-list-direct","chat chmod","chat conversation-info","chat group transfer-owner","chat group-role query-user","chat group-role remove-user","chat group-role set-user","chat message list","chat message send","contact user profile get","attendance +get-leave-records","attendance +get-self-setting","attendance +get-summary","contact account update","contact user update","contact user update-ownness"],"risk":"yellow"}, + "user_ids": {"denotes":"user id list","canonical_hint":"user-ids","members":["users","user-ids"],"excludes":["user","user-id","userid","uid","staff-id","at-user-ids"],"commands":["attendance +check-result","attendance check result","chat +messages-batch-send-by-bot","chat group members remove","chat group set-admin","chat group-mute-member","chat message read-status","chat message search-advanced","chat message send-by-bot","calendar +freebusy","calendar +suggestion","minutes +share","minutes +unshare","minutes permission add","minutes permission remove","wiki +member-add","wiki +member-remove","wiki +member-update","attendance +check-record","attendance +get-checkin-record","attendance +get-schedule","attendance +list-approve","attendance +query-report-data","contact user get"],"risk":"yellow"}, "open_dingtalk_ids": {"denotes":"DingTalk openDingTalkId list with unchanged element values","canonical_hint":"open-dingtalk-ids","members":["open-dingtalk-ids"],"excludes":["user","user-id","user-ids","staff-id","users"],"commands":["chat +chat-members-get","chat category create-smart","chat group members list-by-ids","chat message send-by-bot","ding +send-personal"],"risk":"yellow"}, "ding_id": {"denotes":"DING id","canonical_hint":"ding-id","members":["ding-id","open-ding-id"],"excludes":["id"],"commands":["ding message receiver-status","ding +recall-personal","ding +receiver-status"],"risk":"yellow"}, "folder_id": {"denotes":"drive folder id","canonical_hint":"folder","members":["folder","folder-id"],"excludes":["space-id"],"commands":["drive +copy","drive +create-folder","drive +create-shortcut","drive +list","drive +move","drive +upload","drive commit","drive copy","drive list","drive mkdir","drive move","drive shortcut","drive upload","drive upload-info","mail folder update","markdown create"],"risk":"green"}, @@ -77,7 +77,17 @@ "sheet_a1_range": {"denotes":"single A1 worksheet range with unchanged text","canonical_hint":"range","members":["range"],"excludes":["ranges","a1-ranges","cell-ranges","source-range","target-range","destination-range","start-cell"],"commands":["sheet +read"],"risk":"yellow"}, "sheet_tab_reference": {"denotes":"single worksheet ID accepted unchanged by a sheet command; never a worksheet display name","canonical_hint":"sheet-id","members":["sheet-id","worksheet-id"],"excludes":["id","node","node-id","document-id","target-sheet-id","source-sheet-id","name","title"],"commands":["sheet +read"],"risk":"yellow"}, "markdown_file_name": {"denotes":"display filename for one native Markdown file, passed unchanged and required to end in .md where Runtime requires it","canonical_hint":"name","members":["name","file-name","filename"],"excludes":["file","file-path","node","node-id","title","folder","folder-id"],"commands":["markdown create","markdown overwrite"],"risk":"yellow"}, - "markdown_local_file_path": {"denotes":"local native Markdown file path passed unchanged; never a remote node ID or output destination","canonical_hint":"file","members":["file","file-path","local-file"],"excludes":["file-id","node","node-id","output","output-path","folder","folder-id","content-file"],"commands":["markdown create","markdown diff","markdown overwrite"],"risk":"yellow"} + "markdown_local_file_path": {"denotes":"local native Markdown file path passed unchanged; never a remote node ID or output destination","canonical_hint":"file","members":["file","file-path","local-file"],"excludes":["file-id","node","node-id","output","output-path","folder","folder-id","content-file"],"commands":["markdown create","markdown diff","markdown overwrite"],"risk":"yellow"}, + "aisearch_person_dimension": {"denotes":"one or more comma-separated enterprise person search dimensions passed unchanged","canonical_hint":"dimension","members":["dimension","dimensions","search-dimension","person-dimension","search-by"],"excludes":["type","types","category","scope","field","query","queries","department","position","duty","phone","job-number"],"commands":["aisearch","aisearch person","aisearch +search-person"],"risk":"yellow"}, + "aisearch_content_queries": {"denotes":"comma-separated enterprise content topic keywords with time, type, behavior, and conversational filler already removed","canonical_hint":"queries","members":["queries","topics","topic-queries","content-query","content-queries","search-terms"],"excludes":["query","keyword","question","natural-language","types","time-range","behavior-type","direction","chat-scope"],"commands":["aisearch enterprise","aisearch behavior"],"risk":"yellow"}, + "aisearch_content_types": {"denotes":"comma-separated aisearch content type enum values passed unchanged","canonical_hint":"types","members":["types","content-types","resource-types","search-categories","search-type-list"],"excludes":["type","category","source","query","queries","dimension","behavior-type"],"commands":["aisearch enterprise","aisearch behavior"],"risk":"yellow"}, + "aisearch_time_range": {"denotes":"explicit user-provided natural-language or textual time range passed unchanged","canonical_hint":"time-range","members":["time-range","time-window","date-range","search-period","period"],"excludes":["time","date","range","start","end","since","until","created-at","updated-at"],"commands":["aisearch enterprise","aisearch behavior"],"risk":"yellow"}, + "contact_mobile_number": {"denotes":"one person's mobile phone number with unchanged formatting","canonical_hint":"mobile","members":["mobile","org-user-mobile"],"excludes":["user-id","login-id","email"],"commands":["contact +by-mobile","contact +search-mobile","contact account create","contact user invite","contact user search-mobile"],"risk":"yellow"}, + "contact_org_user_name": {"denotes":"employee display name inside an organization","canonical_hint":"org-user-name","members":["org-user-name","organization-user-name"],"excludes":["org-name","creator-username","nick"],"commands":["contact account create","contact account update","contact user invite","contact user update"],"risk":"yellow"}, + "contact_dept_json_assignments": {"denotes":"department membership JSON array without value transformation","canonical_hint":"depts","members":["department-memberships-json","department-assignments-json"],"excludes":["dept","dept-id","dept-ids","department-ids"],"commands":["contact account update","contact user invite","contact user update"],"risk":"yellow"}, + "hrbrain_pool_code": {"denotes":"single Hrbrain talent-pool code with unchanged value","canonical_hint":"pool-code","members":["pool-code","talent-pool-code"],"excludes":["pool","pool-id","talent-pool-id","code","id","name"],"commands":["hrbrain talent-pool detail","hrbrain talent-pool employees","hrbrain search employees"],"risk":"yellow"}, + "hrbrain_work_no": {"denotes":"single Hrbrain employee work number with unchanged value","canonical_hint":"work-no","members":["work-no","employee-no","employee-number","staff-no"],"excludes":["staff-ids","work-nos","employee-id","staff-id","user-id","id","name"],"commands":["hrbrain profile metadata","hrbrain profile query","hrbrain profile career","hrbrain profile performance"],"risk":"yellow"}, + "hrbrain_work_nos": {"denotes":"Hrbrain employee work-number list with unchanged list encoding","canonical_hint":"staff-ids","members":["staff-ids","work-nos","employee-nos","staff-nos"],"excludes":["work-no","employee-no","staff-no","employee-ids","user-ids","id","name"],"commands":["hrbrain profile labels"],"risk":"yellow"} }, "command_overrides": { @@ -101,7 +111,7 @@ "drive upload": {"ambiguous":["destination-id","space","target-id"],"note":"Local file, display name, MIME, overwrite node, folder, storage space, and knowledge-base workspace remain distinct roles.","scoped_aliases":{"dentry-uuid":"node","directory-id":"folder","overwrite-node-id":"node","target-folder-id":"folder","target-workspace-id":"workspace","source-file":"file","content-type":"mime-type","filename":"file-name","name":"file-name","display-name":"file-name","upload-name":"file-name"},"block":["dentry-id","document-url","output-path"],"scope_strict":true}, "ding +receiver-status": {"scoped_aliases":{"id":"ding-id"},"note":"The only identifier role is one openDingId. Generic id and open-ding-id reduce to ding-id; other resource and request IDs stop before dispatch.","block":["message-id","task-id","uuid","request-id"],"scope_strict":true}, "ding message receiver-status": {"scoped_aliases":{"id":"ding-id"}}, - "contact user profile get": {"scoped_aliases":{"id":"staff-id","ids":"staff-id"},"note":"user-id is reduced by the user_id concept; generic id/ids bound explicitly"}, + "contact user profile get": {"scoped_aliases":{"id":"staff-id","ids":"staff-id","field-codes":"fields","roster-fields":"fields","profile-fields":"fields","field-code-list":"fields"},"note":"user-id is reduced by the user_id concept; generic id/ids bound explicitly Former single/local-role concept contact_roster_fields is intentionally command-scoped. The leaf-local --fields denotes roster field codes and shadows the root output-projection flag when placed after the leaf path.","bind":{"staff-id":"user_id"},"block":["output-fields","staff-ids","user-ids"],"scope_strict":true}, "mail folder update": {"bind":{"id":"folder_id"},"note":"this command's --id is the folder id; --folder-id reduces to --id"}, "mail message search": {"scoped_aliases":{"subject":"query"},"scope_strict":true,"note":"never globalize: mail template create has a real and different --subject"}, "calendar event list": {"scoped_aliases":{"date":"start"},"note":"reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"}, @@ -466,7 +476,47 @@ "markdown diff": {"bind":{"version":"doc_version_number","file":"markdown_local_file_path"},"scoped_aliases":{"dentry-uuid":"node","left-version":"version","base-version":"version","from-version":"version","right-version":"version2","target-version":"version2","to-version":"version2","context-lines":"context","diff-context":"context","surrounding-lines":"context","plan-only":"dry-run","preview-only":"dry-run","source-file":"file"},"block":["version-id","revision","content","body","pattern","regex","output-path","workspace","workspace-id","space-id","folder","name"],"ambiguous":["path","left","right","destination"],"scope_strict":true,"note":"Diff compares remote version(s) or one remote side with a local .md. --version and --version2 are positive history numbers; --file is a local path and is mutually exclusive with --version2. Context-line spellings are local aliases for the one diff-only integer option. Hidden node aliases remain executable compatibility assertions."}, "markdown fetch": {"bind":{"workspace":"workspace_id","space-id":"drive_storage_space_id","output":"local_output_path"},"scoped_aliases":{"document-id":"node","dentry-uuid":"node","save-to":"output","download-to":"output","output-file":"output"},"block":["url","content","file","file-path","folder","parent-id","name","version","version2","pattern","regex"],"ambiguous":["path","space","destination"],"scope_strict":true,"note":"Fetch reads one remote native .md node and may save to one local output path. Workspace IDs, numeric DingDrive space IDs, remote node IDs, local destinations, and content/upload inputs remain distinct. Hidden --id compatibility remains executable but is not promoted as a reviewed alias."}, "markdown overwrite": {"bind":{"content":"content_text","file":"markdown_local_file_path","name":"markdown_file_name","workspace":"workspace_id","space-id":"drive_storage_space_id"},"scoped_aliases":{"document-id":"node","dentry-uuid":"node","markdown-content":"content","show-diff":"dry-run","preview-only":"dry-run","no-write":"dry-run","source-file":"file"},"block":["url","folder","folder-id","parent-id","output-path","version","version2","pattern","regex","replacement","content-file"],"ambiguous":["id","path","space","destination"],"scope_strict":true,"note":"Overwrite is destructive and needs one explicit remote node plus exactly one content transport. Local file paths, inline content, display name, workspace/space route, and command-level diff preview are not interchangeable."}, - "markdown patch": {"bind":{"workspace":"workspace_id","space-id":"drive_storage_space_id"},"scoped_aliases":{"document-id":"node","dentry-uuid":"node","match-pattern":"pattern","find-text":"pattern","old-text":"pattern","search-pattern":"pattern","regexp":"regex","use-regex":"regex","regular-expression":"regex","replacement":"content","replace-with":"content","new-text":"content","replacement-text":"content","show-diff":"dry-run","preview-only":"dry-run","no-write":"dry-run"},"block":["url","file","file-path","content-file","replacement-file","name","folder","folder-id","parent-id","output-path","version","version2","query"],"ambiguous":["id","path","space","text","body","value","destination"],"scope_strict":true,"note":"Patch downloads and reuploads one remote .md. Pattern and regex spellings are local aliases for this leaf; the match pattern and literal replacement remain distinct, and --regex changes only pattern interpretation, never replacement expansion. File-based replacement and role-free text/path inputs are not inferred."} + "markdown patch": {"bind":{"workspace":"workspace_id","space-id":"drive_storage_space_id"},"scoped_aliases":{"document-id":"node","dentry-uuid":"node","match-pattern":"pattern","find-text":"pattern","old-text":"pattern","search-pattern":"pattern","regexp":"regex","use-regex":"regex","regular-expression":"regex","replacement":"content","replace-with":"content","new-text":"content","replacement-text":"content","show-diff":"dry-run","preview-only":"dry-run","no-write":"dry-run"},"block":["url","file","file-path","content-file","replacement-file","name","folder","folder-id","parent-id","output-path","version","version2","query"],"ambiguous":["id","path","space","text","body","value","destination"],"scope_strict":true,"note":"Patch downloads and reuploads one remote .md. Pattern and regex spellings are local aliases for this leaf; the match pattern and literal replacement remain distinct, and --regex changes only pattern interpretation, never replacement expansion. File-based replacement and role-free text/path inputs are not inferred."}, + "aisearch": {"bind":{"dimension":"aisearch_person_dimension"},"scoped_aliases":{"search-term":"query","person-query":"query","person-name":"query"},"block":["queries","types","search-types","time-range","behavior-type","action-type","direction","chat-scope","user-id","open-dingtalk-id","mobile","phone-number","job-number","employee-number","department-id"],"ambiguous":["target","id","phone","employee","department","position","duty","scope"],"scope_strict":true,"note":"The runnable compatibility root is person search only when a person query is present. Enterprise/behavior slots, exact IDs, full-mobile lookup, and role-free dimension names must not be interpreted as a person query. Native keyword/name/q/text/type compatibility remains unchanged."}, + "aisearch person": {"bind":{"dimension":"aisearch_person_dimension"},"scoped_aliases":{"search-term":"query","person-query":"query","person-name":"query"},"block":["queries","types","search-types","time-range","behavior-type","action-type","direction","chat-scope","user-id","open-dingtalk-id","ids","mobile","phone-number","job-number","employee-number","department-id","position-id"],"ambiguous":["target","id","phone","employee","department","position","duty","scope"],"scope_strict":true,"note":"Person search requires a complete target string plus an explicit dimension when the user provides one. Full mobile lookup and known user IDs belong to contact; the alias layer cannot set dimension values or turn a role flag into query+dimension. Native keyword/name/q/text/type flags and command aliases remain original compatibility paths."}, + "aisearch enterprise": {"bind":{"queries":"aisearch_content_queries","types":"aisearch_content_types","time-range":"aisearch_time_range"},"block":["dimension","dimensions","behavior-type","action-type","direction","from","to","sender","receiver","chat-scope","group-name","user-id","open-dingtalk-id","node-id","file-id","document-id","write","update"],"ambiguous":["type","category","source","range","date","time","scope","question","natural-language"],"scope_strict":true,"note":"Enterprise search expects already separated topic queries, content type enums, and an explicit time phrase. It finds content but does not read or write a selected resource. Behavior/direction/person slots and generalized natural-language or type/range flags are not inferred. Native query/keyword/search-types/searchTypes/timeRange compatibility remains original."}, + "aisearch behavior": {"bind":{"queries":"aisearch_content_queries","types":"aisearch_content_types","time-range":"aisearch_time_range"},"scoped_aliases":{"action-type":"behavior-type","behavior-action":"behavior-type","activity-type":"behavior-type","group-name":"chat-scope","chat-name":"chat-scope","conversation-name":"chat-scope","im-scope":"chat-scope","interaction-direction":"direction","interaction-flow":"direction","message-flow":"direction"},"block":["dimension","dimensions","user-id","open-dingtalk-id","node-id","file-id","document-id","from","to","sender","receiver","chat-id","group-id","conversation-id","open-conversation-id","write","update"],"ambiguous":["type","category","scope","chat","group","conversation","action","actor","target","range","date","time","source","destination"],"scope_strict":true,"note":"Enterprise behavior search keeps reusable query/type/time concepts. Behavior enum, chat-name scope, and direction are leaf-local roles, so their reviewed synonyms are command-scoped instead of becoming single-command global concepts. Stable IDs and split sender/receiver fields are never converted."}, + "aisearch +search-person": {"bind":{"dimensions":"aisearch_person_dimension"},"scoped_aliases":{"search-term":"query","person-query":"query","person-name":"query"},"block":["queries","types","search-types","time-range","behavior-type","action-type","direction","chat-scope","user-id","open-dingtalk-id","ids","mobile","phone-number","job-number","employee-number","department-id","position-id"],"ambiguous":["target","id","phone","employee","department","position","duty","scope"],"scope_strict":true,"note":"The available shortcut accepts one nonblank person query and an optional validated dimension list. Person-name spellings preserve the query value; full mobile, employee IDs, content/behavior slots, and role-free words are stopped rather than guessed."}, + "contact +lookup": {"scoped_aliases":{"employee-name":"name","staff-name":"name","person-name":"name","user-name":"name","flower-name":"name"},"block":["department-id","dept","dept-id","org-name","role-name"],"scope_strict":true,"note":"Former single/local-role concept contact_person_name_query is intentionally command-scoped. The real --name is a person/flower-name lookup string; department, organization, and role names are different entities."}, + "contact +org": {"scoped_aliases":{"employee-name":"name","staff-name":"name","person-name":"name","user-name":"name","flower-name":"name"},"block":["department-id","dept","dept-id","org-name","role-name"],"scope_strict":true,"note":"Former single/local-role concept contact_person_name_query is intentionally command-scoped. The real --name is a person/flower-name lookup string; department, organization, and role names are different entities."}, + "contact +team": {"scoped_aliases":{"employee-name":"name","staff-name":"name","person-name":"name","user-name":"name","flower-name":"name"},"block":["department-id","dept","dept-id","org-name","role-name"],"scope_strict":true,"note":"Former single/local-role concept contact_person_name_query is intentionally command-scoped. The real --name is a person/flower-name lookup string; department, organization, and role names are different entities."}, + "contact user dismission search": {"bind":{"depts":"dept_ids"},"scoped_aliases":{"employee-name":"name","staff-name":"name","person-name":"name","user-name":"name","flower-name":"name"},"block":["cursor","department-id","department-memberships-json","dept","dept-id","dept-list-json","offset"],"scope_strict":true,"note":"Former single/local-role concept contact_person_name_query is intentionally command-scoped. This command uses CSV department IDs, a date range, and one-based page/limit; JSON memberships and cursor/offset pagination are different protocols."}, + "contact +list-role-members": {"block":["dept","dept-id","ids","names","staff-id","user-id"],"scope_strict":true,"note":"Former single/local-role concept contact_role_id is intentionally command-scoped. The real --id is one label/role ID, not a department ID, userId, or list.","scoped_aliases":{"role-id":"id","label-id":"id"}}, + "contact label get": {"block":["dept-id","id","label-id","role-id","user-id"],"scope_strict":true,"note":"Former single/local-role concept contact_role_names is intentionally command-scoped. The real --names is a comma-separated exact role-name list; native name/query/keyword spellings remain compatibility behavior.","scoped_aliases":{"role-names":"names","label-names":"names"}}, + "contact user update-ownness": {"bind":{"user-id":"user_id"},"scope_strict":true,"note":"Former single/local-role concept contact_status_text is intentionally command-scoped. Target userId and the personal status text are separate roles; status values are passed unchanged.","scoped_aliases":{"status-text":"ownness-text","personal-status":"ownness-text","status":"ownness-text"}}, + "contact org create": {"ambiguous":["name"],"block":["login-id","user-id"],"scope_strict":true,"note":"Former single/local-role concept contact_organization_name is intentionally command-scoped. Former single/local-role concept contact_creator_name is intentionally command-scoped. Organization name and creator display name are distinct write roles; a generic name cannot choose one.","scoped_aliases":{"organization-name":"org-name","company-name":"org-name","corp-name":"org-name","creator-name":"creator-username","founder-name":"creator-username"}}, + "contact account create": {"bind":{"dept-ids":"dept_ids"},"scoped_aliases":{"employee-name":"org-user-name","staff-name":"org-user-name","account-login-id":"login-id","login-account":"login-id","email-address":"email","mail-address":"email"},"block":["department-id","department-memberships-json","dept","dept-id","depts-json","staff-id","user-id"],"scope_strict":true,"note":"Former single/local-role concept contact_login_id is intentionally command-scoped. Former single/local-role concept contact_email_address is intentionally command-scoped. Department IDs are a CSV list here; login-id, userId, email, and mobile are distinct account identifiers."}, + "contact user invite": {"bind":{"depts":"contact_dept_json_assignments"},"scoped_aliases":{"employee-name":"org-user-name","staff-name":"org-user-name"},"block":["department-id","department-ids","dept","dept-id","dept-ids","parent","parent-id"],"scope_strict":true,"note":"The real --depts value is a JSON membership array; scalar or CSV department IDs must not be renamed into it."}, + "contact user update": {"bind":{"depts":"contact_dept_json_assignments","user-id":"user_id"},"scoped_aliases":{"employee-name":"org-user-name","staff-name":"org-user-name"},"block":["department-id","department-ids","dept","dept-id","dept-ids","parent","parent-id"],"scope_strict":true,"note":"The employee being changed and the employee's direct manager are distinct userId roles.","ambiguous":["manager","owner-id"]}, + "contact account update": {"bind":{"depts":"contact_dept_json_assignments","user-id":"user_id"},"scoped_aliases":{"employee-name":"org-user-name","staff-name":"org-user-name","profile-image-file-id":"avatar-file-id"},"block":["department-id","department-ids","dept","dept-id","dept-ids","parent","parent-id","ownness-text","file","node-id"],"scope_strict":true,"note":"The employee being changed and the employee's direct manager are distinct userId roles. Nickname and manager are local flag roles, not reusable central concepts. Avatar file synonyms are command-scoped because both avatar commands already share the native --avatar-file-id canonical flag.","ambiguous":["manager","owner-id"]}, + "contact dept update": {"scoped_aliases":{"target-dept-id":"dept","target-department-id":"dept","parent-dept-id":"parent","parent-department-id":"parent","new-parent-dept-id":"parent"},"ambiguous":["department","target-id"],"block":["department-ids","depts"],"scope_strict":true,"note":"The department being updated and its new parent are separate single-department roles."}, + "contact dept get-info": {"scoped_aliases":{"department-id":"dept"},"block":["department","department-ids","depts","parent","parent-id"],"scope_strict":true,"note":"The real target is one department ID. Native dept-id/dept-ids/id/ids spellings remain native compatibility; parent-role names are not rewritten."}, + "contact dept create": {"scoped_aliases":{"parent-dept-id":"parent","parent-department-id":"parent","new-parent-dept-id":"parent"},"block":["department-ids","dept","dept-id","dept-ids","depts"],"scope_strict":true,"note":"Only a parent department is accepted; there is no target department ID on create."}, + "contact user get": {"bind":{"ids":"user_ids"},"scoped_aliases":{"staff-ids":"ids","member-user-ids":"ids"},"block":["staff-id","uid"],"scope_strict":true,"note":"The target is a userId list; native user-id/userid compatibility remains native and can represent a one-item list."}, + "contact label list-members": {"block":["dept","dept-id","ids","names","staff-id","user-id"],"scope_strict":true,"note":"The real --id is one label/role ID, not a department ID, userId, or list."}, + "contact user update-self": {"block":["name","org-user-name","ownness-text","file","node-id","user-id"],"scope_strict":true,"note":"Nickname is local to self-update; employee-name and ownership-text spellings remain guarded. Avatar file synonyms are command-scoped because both avatar commands already share the native --avatar-file-id canonical flag.","scoped_aliases":{"profile-image-file-id":"avatar-file-id"}}, + "contact +list-followings": {"scoped_aliases":{"open-dingtalk-id":"open-id","open-dingtalkid":"open-id"},"block":["user-id","userid","staff-id","mobile","phone","email","name","query","open-dingtalk-ids"],"ambiguous":["id","user","person"],"scope_strict":true,"note":"The optional --open-id is one openDingTalkId used only to filter the current user's followings. It is a single-command role, so it intentionally remains scoped and must not be conflated with userId, staffId, mobile, or a list."}, + "live stream list": {"block":["page","size","limit","cursor","offset","page-token","page-size","status","state","query","keyword","sort","order","live-id","stream-id","room-id","anchor-id","user-id","creator-id","start","end","start-time","end-time","from","to","title","cover-url","group-id","conversation-id","record","playback","create","start-live","end-live"],"ambiguous":["id","type","time","date","user","owner"],"scope_strict":true,"note":"The frozen Live surface has one read-only get_my_lives leaf and no business parameters. It always queries the current user's live list. Pagination, filters, live/stream/user IDs, time ranges, and create/control/playback fields are not accepted inputs. They must stop before MCP dispatch rather than being guessed from returned list fields or other live-management APIs. Global output, profile, timeout, mock, dry-run, and credential flags remain native."}, + "devdoc article search": {"scoped_aliases":{"search-term":"query","query-text":"query","results-per-page":"size","page-number":"page"},"block":["offset","cursor","page-index","page-token","next-token","next-cursor","count","total","doc-id","document-id","file-id","node-id","space-id","workspace-id","request-id","trace-id","error-code","error-message","context"],"ambiguous":["id","type"],"scope_strict":true,"note":"Devdoc search accepts one unchanged query string plus one-based page and per-page size. The raw leaf also owns a positional keyword and hidden native --keyword compatibility flag; both remain native. Cursor/offset/index pagination, document identifiers, diagnostic fields, and fields that require query composition must stop before dispatch."}, + "hrbrain talent-pool list": {"scoped_aliases":{"label-list":"labels"},"block":["pool-code","pool-id","talent-pool-id","employee-id","work-no","staff-ids","user-id"],"ambiguous":["id","type","user","code","creator-id","label-name","pool"],"scope_strict":true,"note":"Talent-pool list accepts a name keyword, pool type, creator value, labels, one-based page, and page-size. Search and pagination aliases preserve values. Unproven creator-id/label-name value domains, pool identifiers, employee identifiers, and generic id/type/code spellings are not promoted."}, + "hrbrain talent-pool detail": {"block":["page","page-size","query","keyword","creator","labels","employee-id","work-no","staff-ids"],"ambiguous":["id","code","name","type","pool"],"scope_strict":true,"note":"Talent-pool detail requires exactly one stable poolCode. Only explicit talent-pool-code preserves that value. Bare pool/id/code/name spellings and list filters cannot prove the same value domain."}, + "hrbrain talent-pool employees": {"block":["query","keyword","dept-name","position-name","job-level","creator","labels","employee-id","work-no","staff-ids"],"ambiguous":["id","code","name","status","pool"],"scope_strict":true,"note":"Pool employee listing requires one poolCode plus one-based page and page-size. It does not expose free search, department/job filters, work-number selectors, or status filtering; bare pool/id/code cannot prove poolCode."}, + "hrbrain profile metadata": {"block":["data-queries","model-code","module","section","employee-id","staff-id","user-id","staff-ids"],"ambiguous":["id","user","employee","name","field"],"scope_strict":true,"note":"Profile metadata accepts one employee work number and returns module/field metadata. Data queries, identifiers from other domains, list cardinality, and returned field roles are not request aliases."}, + "hrbrain profile query": {"scoped_aliases":{"profile-queries":"data-queries","module-queries":"data-queries"},"block":["data-query","model-code","module","section","employee-id","staff-id","user-id","staff-ids"],"ambiguous":["id","user","employee","name","field","query"],"scope_strict":true,"note":"Profile query requires one work number and one non-empty JSON array in data-queries. Reviewed plural query aliases preserve the whole JSON value; singular or nested members cannot be expanded into that structure."}, + "hrbrain profile labels": {"scoped_aliases":{"all-labels":"all-label"},"block":["work-no","employee-no","staff-no","employee-ids","user-ids","employee-id","staff-id","user-id"],"ambiguous":["id","user","employee","name","label","tag"],"scope_strict":true,"note":"Profile labels requires a list of employee work numbers. Plural work-number spellings preserve list encoding and all-labels preserves boolean polarity. Singular values and platform user/staff identifiers differ in cardinality or value domain."}, + "hrbrain profile career": {"block":["start","end","start-date","end-date","year","position","job-level","employee-id","staff-id","user-id","staff-ids"],"ambiguous":["id","user","employee","name","time","date"],"scope_strict":true,"note":"Career accepts one employee work number and returns full company career history. Date, position, and level are return fields rather than request filters."}, + "hrbrain profile performance": {"block":["start","end","start-date","end-date","year","cycle","rating","score","employee-id","staff-id","user-id","staff-ids"],"ambiguous":["id","user","employee","name","time","date","level"],"scope_strict":true,"note":"Performance accepts one employee work number and exposes no cycle/date/rating/score filter. Those returned record roles and other identifier domains must stop before dispatch."}, + "hrbrain search employees": {"scoped_aliases":{"department-name":"dept-name","job-title":"position-name","position-level":"job-level"},"block":["dept-id","department-id","employee-id","staff-id","user-id","work-no","staff-ids","origin-json","data-queries","order-by"],"ambiguous":["id","code","name","department","level","grade","title","user","position","pool"],"scope_strict":true,"note":"Simple employee search accepts unchanged keyword, department name, position name, job level, poolCode, page, and page-size values. Explicit name/level aliases preserve roles; ids, work numbers, structured JSON, bare position/pool, and generic level/title names are not promoted."}, + "hrbrain search employees-structured": {"scoped_aliases":{"filter-json":"origin-json","criteria-json":"origin-json","search-json":"origin-json","return-fields-json":"fields","result-fields-json":"fields","sort-by":"order-by","sort-fields":"order-by"},"block":["keyword","query","dept-name","department-name","position-name","job-level","pool-code","work-no","staff-ids","field-name","operator","operators"],"ambiguous":["id","name","type","filter","criteria","field","sort","order"],"scope_strict":true,"note":"Structured search requires a JSON object in origin-json, a non-empty JSON array in the local business fields flag, and optional order-by values. Reviewed aliases preserve each encoding; simple-search flags and nested members cannot become peers."}, + "hrbrain search fields": {"block":["query","keyword","origin-json","order-by","page","page-size","dept-name","position-name","job-level","pool-code","work-no","staff-ids","field-name","operator","operators"],"ambiguous":["id","name","type","field","filter","criteria"],"scope_strict":true,"note":"Search fields has no business inputs and returns the current operator's allowed structured-search fields/operators. Search criteria, pagination, employee filters, and returned field/operator roles are not request parameters."}, + "pat chmod": {"scoped_aliases":{"product-code":"product","product-codes":"products","domain-code":"domain","domain-codes":"domains","grant-mode":"grant-type","authorization-mode":"grant-type","pat-session-id":"session-id","authorization-session-id":"session-id","recommended":"recommend"},"block":["scope","scopes","permission","permissions","permission-scope","permission-scopes","all","all-scopes","force","confirm","confirmed","app-id","app-key","app-secret","oauth-scope","api-scope","open-browser","browser-enabled","allow-browser","disable-browser","browser-policy"],"ambiguous":["agent","agent-id","type","mode","session","id","service","services","resource","action"],"scope_strict":true,"note":"PAT chmod accepts scopes only as positional argv or through reviewed product/domain/recommend plan selectors. Aliases preserve selector cardinality, grant enum values, and session ids. Scope flags cannot become positionals; app permission, browser policy, generic ids, force/confirm, and all-scope spellings must not be promoted."}, + "pat browser-policy": {"scoped_aliases":{"open-browser":"enabled","browser-enabled":"enabled","allow-browser":"enabled"},"block":["disable-browser","browser-disabled","deny-browser","block-browser","scope","scopes","permission","permissions","product","products","domain","domains","grant-type","grant-mode","session-id","recommend","recommended","app-id","app-key","app-secret","oauth-scope","api-scope"],"ambiguous":["agent","agent-id","policy","browser","state","value","mode"],"scope_strict":true,"note":"Browser policy requires one explicit positive-polarity boolean and optionally one explicit agent code. Positive browser spellings preserve the boolean unchanged. Disable/deny names require inversion; authorization scopes, app permissions, and generic policy/agent/value names are different roles."}, + "pat +browser-policy": {"scoped_aliases":{"open-browser":"enabled","browser-enabled":"enabled","allow-browser":"enabled"},"block":["disable-browser","browser-disabled","deny-browser","block-browser","scope","scopes","permission","permissions","product","products","domain","domains","grant-type","grant-mode","session-id","recommend","recommended","app-id","app-key","app-secret","oauth-scope","api-scope"],"ambiguous":["agent","agent-id","policy","browser","state","value","mode"],"scope_strict":true,"note":"Browser policy requires one explicit positive-polarity boolean and optionally one explicit agent code. Positive browser spellings preserve the boolean unchanged. Disable/deny names require inversion; authorization scopes, app permissions, and generic policy/agent/value names are different roles."} }, "validation_fixture": { @@ -1277,7 +1327,223 @@ {"command":"markdown patch","emitted":"preview-only","expect":"dry-run","via":"override:preview-safety"}, {"command":"markdown patch","emitted":"regexp","expect":"regex","via":"override:patch-regex"}, {"command":"markdown patch","emitted":"replacement","expect":"content","via":"override:replacement-role"}, - {"command":"markdown patch","emitted":"text","expect":"did-you-mean:ambiguous","via":"guard:pattern-vs-replacement"} + {"command":"markdown patch","emitted":"text","expect":"did-you-mean:ambiguous","via":"guard:pattern-vs-replacement"}, + {"command":"aisearch","emitted":"search-term","expect":"query","via":"override:person-target"}, + {"command":"aisearch","emitted":"dimensions","expect":"dimension","via":"concept:aisearch_person_dimension"}, + {"command":"aisearch","emitted":"types","expect":"did-you-mean:blocked","via":"guard:compat-root-is-person-only"}, + {"command":"aisearch","emitted":"target","expect":"did-you-mean:ambiguous","via":"guard:target-vs-dimension-role"}, + {"command":"aisearch person","emitted":"person-name","expect":"query","via":"override:person-target"}, + {"command":"aisearch person","emitted":"search-by","expect":"dimension","via":"concept:aisearch_person_dimension"}, + {"command":"aisearch person","emitted":"queries","expect":"did-you-mean:blocked","via":"guard:person-vs-content-query-list"}, + {"command":"aisearch person","emitted":"mobile","expect":"did-you-mean:blocked","via":"guard:full-mobile-routes-to-contact"}, + {"command":"aisearch person","emitted":"job-number","expect":"did-you-mean:blocked","via":"guard:requires-query-plus-dimension"}, + {"command":"aisearch person","emitted":"target","expect":"did-you-mean:ambiguous","via":"guard:target-vs-dimension-role"}, + {"command":"aisearch enterprise","emitted":"topics","expect":"queries","via":"concept:aisearch_content_queries"}, + {"command":"aisearch enterprise","emitted":"content-types","expect":"types","via":"concept:aisearch_content_types"}, + {"command":"aisearch enterprise","emitted":"time-window","expect":"time-range","via":"concept:aisearch_time_range"}, + {"command":"aisearch enterprise","emitted":"behavior-type","expect":"did-you-mean:blocked","via":"guard:content-vs-behavior"}, + {"command":"aisearch enterprise","emitted":"direction","expect":"did-you-mean:blocked","via":"guard:no-behavior-flow"}, + {"command":"aisearch enterprise","emitted":"node-id","expect":"did-you-mean:blocked","via":"guard:search-vs-resource-read"}, + {"command":"aisearch enterprise","emitted":"type","expect":"did-you-mean:ambiguous","via":"guard:content-type-list-role"}, + {"command":"aisearch enterprise","emitted":"question","expect":"did-you-mean:ambiguous","via":"guard:no-natural-language-slotting"}, + {"command":"aisearch behavior","emitted":"topics","expect":"queries","via":"concept:aisearch_content_queries"}, + {"command":"aisearch behavior","emitted":"resource-types","expect":"types","via":"concept:aisearch_content_types"}, + {"command":"aisearch behavior","emitted":"period","expect":"time-range","via":"concept:aisearch_time_range"}, + {"command":"aisearch behavior","emitted":"action-type","expect":"behavior-type","via":"override:behavior-role"}, + {"command":"aisearch behavior","emitted":"group-name","expect":"chat-scope","via":"override:chat-scope-role"}, + {"command":"aisearch behavior","emitted":"interaction-flow","expect":"direction","via":"override:direction-role"}, + {"command":"aisearch behavior","emitted":"dimension","expect":"did-you-mean:blocked","via":"guard:behavior-vs-person"}, + {"command":"aisearch behavior","emitted":"from","expect":"did-you-mean:blocked","via":"guard:cannot-construct-direction"}, + {"command":"aisearch behavior","emitted":"chat-id","expect":"did-you-mean:blocked","via":"guard:chat-scope-expects-name"}, + {"command":"aisearch behavior","emitted":"type","expect":"did-you-mean:ambiguous","via":"guard:content-type-list-role"}, + {"command":"aisearch behavior","emitted":"action","expect":"did-you-mean:ambiguous","via":"guard:behavior-enum-role"}, + {"command":"aisearch behavior","emitted":"target","expect":"did-you-mean:ambiguous","via":"guard:actor-direction-target"}, + {"command":"aisearch +search-person","emitted":"keyword","expect":"query","via":"concept:search_query"}, + {"command":"aisearch +search-person","emitted":"person-name","expect":"query","via":"override:person-target"}, + {"command":"aisearch +search-person","emitted":"search-by","expect":"dimensions","via":"concept:aisearch_person_dimension+bind"}, + {"command":"aisearch +search-person","emitted":"mobile","expect":"did-you-mean:blocked","via":"guard:full-mobile-routes-to-contact"}, + {"command":"aisearch +search-person","emitted":"target","expect":"did-you-mean:ambiguous","via":"guard:target-vs-dimension-role"}, + {"command":"contact +by-mobile","emitted":"org-user-mobile","expect":"mobile","via":"full-product-review:alias"}, + {"command":"contact +by-mobile","emitted":"email","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +list-role-members","emitted":"label-id","expect":"id","via":"full-product-review:alias"}, + {"command":"contact +list-role-members","emitted":"dept","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +lookup","emitted":"employee-name","expect":"name","via":"full-product-review:alias"}, + {"command":"contact +lookup","emitted":"department-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +org","emitted":"employee-name","expect":"name","via":"full-product-review:alias"}, + {"command":"contact +org","emitted":"department-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +search-mobile","emitted":"org-user-mobile","expect":"mobile","via":"full-product-review:alias"}, + {"command":"contact +search-mobile","emitted":"email","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +team","emitted":"employee-name","expect":"name","via":"full-product-review:alias"}, + {"command":"contact +team","emitted":"department-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact account create","emitted":"organization-user-name","expect":"org-user-name","via":"full-product-review:alias"}, + {"command":"contact account create","emitted":"creator-username","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact account update","emitted":"department-assignments-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact account update","emitted":"department-memberships-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact account update","emitted":"manager","expect":"did-you-mean:ambiguous","via":"full-product-review:guard"}, + {"command":"contact dept create","emitted":"parent-department-id","expect":"parent","via":"full-product-review:alias"}, + {"command":"contact dept create","emitted":"department-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact dept get-info","emitted":"department-id","expect":"dept","via":"full-product-review:alias"}, + {"command":"contact dept get-info","emitted":"department","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact dept list-members","emitted":"department-ids","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact dept list-members","emitted":"department-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact dept search","emitted":"search-word","expect":"query","via":"full-product-review:alias"}, + {"command":"contact dept search","emitted":"subject","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact dept update","emitted":"parent-department-id","expect":"parent","via":"full-product-review:alias"}, + {"command":"contact dept update","emitted":"target-department-id","expect":"dept","via":"full-product-review:alias"}, + {"command":"contact dept update","emitted":"department","expect":"did-you-mean:ambiguous","via":"full-product-review:guard"}, + {"command":"contact label get","emitted":"label-names","expect":"names","via":"full-product-review:alias"}, + {"command":"contact label get","emitted":"dept-id","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact label list-members","emitted":"dept","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact org create","emitted":"organization-name","expect":"org-name","via":"full-product-review:alias"}, + {"command":"contact org create","emitted":"name","expect":"did-you-mean:ambiguous","via":"full-product-review:guard"}, + {"command":"contact user dismission search","emitted":"department-ids","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact user dismission search","emitted":"count","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user get","emitted":"member-user-ids","expect":"ids","via":"full-product-review:alias"}, + {"command":"contact user get","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user invite","emitted":"department-assignments-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact user invite","emitted":"department-memberships-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact user invite","emitted":"creator-username","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user profile get","emitted":"field-code-list","expect":"fields","via":"full-product-review:alias"}, + {"command":"contact user profile get","emitted":"output-fields","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user search","emitted":"search-word","expect":"query","via":"full-product-review:alias"}, + {"command":"contact user search","emitted":"subject","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user search-mobile","emitted":"org-user-mobile","expect":"mobile","via":"full-product-review:alias"}, + {"command":"contact user search-mobile","emitted":"email","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user update","emitted":"department-assignments-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact user update","emitted":"department-memberships-json","expect":"depts","via":"full-product-review:alias"}, + {"command":"contact user update","emitted":"manager","expect":"did-you-mean:ambiguous","via":"full-product-review:guard"}, + {"command":"contact user update-ownness","emitted":"personal-status","expect":"ownness-text","via":"full-product-review:alias"}, + {"command":"contact user update-ownness","emitted":"at-user-ids","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact user update-self","emitted":"profile-image-file-id","expect":"avatar-file-id","via":"full-product-review:alias"}, + {"command":"contact user update-self","emitted":"file","expect":"did-you-mean:blocked","via":"full-product-review:guard"}, + {"command":"contact +list-followings","emitted":"open-dingtalk-id","expect":"open-id","via":"override:exact-open-id-role"}, + {"command":"contact +list-followings","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:open-dingtalk-id-vs-user-id"}, + {"command":"contact +list-followings","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:unspecified-identifier"}, + {"command":"contact +list-dept-members","emitted":"department-ids","expect":"depts","via":"concept:dept_ids"}, + {"command":"contact +list-dept-members","emitted":"dept-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list"}, + {"command":"contact account update","emitted":"profile-image-file-id","expect":"avatar-file-id","via":"override:avatar-file-role"}, + {"command":"live stream list","emitted":"page","expect":"did-you-mean:blocked","via":"guard:no-pagination-input"}, + {"command":"live stream list","emitted":"limit","expect":"did-you-mean:blocked","via":"guard:no-pagination-input"}, + {"command":"live stream list","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:no-pagination-input"}, + {"command":"live stream list","emitted":"status","expect":"did-you-mean:blocked","via":"guard:return-field-not-filter"}, + {"command":"live stream list","emitted":"query","expect":"did-you-mean:blocked","via":"guard:no-search-input"}, + {"command":"live stream list","emitted":"live-id","expect":"did-you-mean:blocked","via":"guard:list-vs-single-live"}, + {"command":"live stream list","emitted":"anchor-id","expect":"did-you-mean:blocked","via":"guard:implicit-current-user"}, + {"command":"live stream list","emitted":"start-time","expect":"did-you-mean:blocked","via":"guard:no-time-range"}, + {"command":"live stream list","emitted":"title","expect":"did-you-mean:blocked","via":"guard:list-vs-create"}, + {"command":"live stream list","emitted":"group-id","expect":"did-you-mean:blocked","via":"guard:no-audience-control"}, + {"command":"live stream list","emitted":"playback","expect":"did-you-mean:blocked","via":"guard:no-playback-control"}, + {"command":"live stream list","emitted":"start-live","expect":"did-you-mean:blocked","via":"guard:no-live-control"}, + {"command":"live stream list","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:unspecified-live-or-user-id"}, + {"command":"live stream list","emitted":"type","expect":"did-you-mean:ambiguous","via":"guard:no-type-filter"}, + {"command":"live stream list","emitted":"time","expect":"did-you-mean:ambiguous","via":"guard:no-time-filter"}, + {"command":"live stream list","emitted":"user","expect":"did-you-mean:ambiguous","via":"guard:implicit-current-user"}, + {"command":"devdoc article search","emitted":"q","expect":"query","via":"concept:search_query"}, + {"command":"devdoc article search","emitted":"keywords","expect":"query","via":"concept:search_query"}, + {"command":"devdoc article search","emitted":"search-word","expect":"query","via":"concept:search_query"}, + {"command":"devdoc article search","emitted":"search-term","expect":"query","via":"override:scoped-query"}, + {"command":"devdoc article search","emitted":"query-text","expect":"query","via":"override:scoped-query"}, + {"command":"devdoc article search","emitted":"results-per-page","expect":"size","via":"override:scoped-page-size"}, + {"command":"devdoc article search","emitted":"page-number","expect":"page","via":"override:scoped-page-number"}, + {"command":"devdoc article search","emitted":"offset","expect":"did-you-mean:blocked","via":"guard:page-vs-offset"}, + {"command":"devdoc article search","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:page-vs-cursor"}, + {"command":"devdoc article search","emitted":"page-index","expect":"did-you-mean:blocked","via":"guard:one-based-vs-index"}, + {"command":"devdoc article search","emitted":"doc-id","expect":"did-you-mean:blocked","via":"guard:search-vs-object-id"}, + {"command":"devdoc article search","emitted":"error-code","expect":"did-you-mean:blocked","via":"guard:diagnostic-field-needs-query"}, + {"command":"devdoc article search","emitted":"context","expect":"did-you-mean:blocked","via":"guard:requires-query-composition"}, + {"command":"devdoc article search","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:unspecified-id-role"}, + {"command":"devdoc article search","emitted":"type","expect":"did-you-mean:ambiguous","via":"guard:unspecified-type-role"}, + {"command":"hrbrain talent-pool list","emitted":"q","expect":"keyword","via":"concept:search_query"}, + {"command":"hrbrain talent-pool list","emitted":"limit","expect":"page-size","via":"concept:pagination_size"}, + {"command":"hrbrain talent-pool list","emitted":"current-page","expect":"page","via":"concept:page_number"}, + {"command":"hrbrain talent-pool list","emitted":"label-list","expect":"labels","via":"override:labels"}, + {"command":"hrbrain talent-pool list","emitted":"pool-code","expect":"did-you-mean:blocked","via":"guard:list-vs-detail"}, + {"command":"hrbrain talent-pool list","emitted":"creator-id","expect":"did-you-mean:ambiguous","via":"guard:unproven-creator-domain"}, + {"command":"hrbrain talent-pool detail","emitted":"talent-pool-code","expect":"pool-code","via":"concept:hrbrain_pool_code"}, + {"command":"hrbrain talent-pool detail","emitted":"page","expect":"did-you-mean:blocked","via":"guard:detail-vs-list"}, + {"command":"hrbrain talent-pool detail","emitted":"pool","expect":"did-you-mean:ambiguous","via":"guard:pool-name-vs-code"}, + {"command":"hrbrain talent-pool employees","emitted":"talent-pool-code","expect":"pool-code","via":"concept:hrbrain_pool_code"}, + {"command":"hrbrain talent-pool employees","emitted":"limit","expect":"page-size","via":"concept:pagination_size"}, + {"command":"hrbrain talent-pool employees","emitted":"page-no","expect":"page","via":"concept:page_number"}, + {"command":"hrbrain talent-pool employees","emitted":"keyword","expect":"did-you-mean:blocked","via":"guard:list-vs-search"}, + {"command":"hrbrain talent-pool employees","emitted":"pool","expect":"did-you-mean:ambiguous","via":"guard:pool-name-vs-code"}, + {"command":"hrbrain profile metadata","emitted":"employee-no","expect":"work-no","via":"concept:hrbrain_work_no"}, + {"command":"hrbrain profile metadata","emitted":"data-queries","expect":"did-you-mean:blocked","via":"guard:metadata-vs-query"}, + {"command":"hrbrain profile metadata","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:work-number-vs-id"}, + {"command":"hrbrain profile query","emitted":"employee-number","expect":"work-no","via":"concept:hrbrain_work_no"}, + {"command":"hrbrain profile query","emitted":"profile-queries","expect":"data-queries","via":"override:json-array"}, + {"command":"hrbrain profile query","emitted":"module-queries","expect":"data-queries","via":"override:json-array"}, + {"command":"hrbrain profile query","emitted":"data-query","expect":"did-you-mean:blocked","via":"guard:singular-vs-json-array"}, + {"command":"hrbrain profile query","emitted":"query","expect":"did-you-mean:ambiguous","via":"guard:search-vs-profile-query"}, + {"command":"hrbrain profile labels","emitted":"work-nos","expect":"staff-ids","via":"concept:hrbrain_work_nos"}, + {"command":"hrbrain profile labels","emitted":"employee-nos","expect":"staff-ids","via":"concept:hrbrain_work_nos"}, + {"command":"hrbrain profile labels","emitted":"all-labels","expect":"all-label","via":"override:plural-boolean"}, + {"command":"hrbrain profile labels","emitted":"work-no","expect":"did-you-mean:blocked","via":"guard:single-vs-list"}, + {"command":"hrbrain profile labels","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:work-number-vs-user-id"}, + {"command":"hrbrain profile labels","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:unspecified-id"}, + {"command":"hrbrain profile career","emitted":"staff-no","expect":"work-no","via":"concept:hrbrain_work_no"}, + {"command":"hrbrain profile career","emitted":"start-date","expect":"did-you-mean:blocked","via":"guard:return-history-not-filter"}, + {"command":"hrbrain profile career","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:work-number-vs-id"}, + {"command":"hrbrain profile performance","emitted":"employee-no","expect":"work-no","via":"concept:hrbrain_work_no"}, + {"command":"hrbrain profile performance","emitted":"rating","expect":"did-you-mean:blocked","via":"guard:return-record-not-filter"}, + {"command":"hrbrain profile performance","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:work-number-vs-id"}, + {"command":"hrbrain search employees","emitted":"q","expect":"keyword","via":"concept:search_query"}, + {"command":"hrbrain search employees","emitted":"limit","expect":"page-size","via":"concept:pagination_size"}, + {"command":"hrbrain search employees","emitted":"current-page","expect":"page","via":"concept:page_number"}, + {"command":"hrbrain search employees","emitted":"talent-pool-code","expect":"pool-code","via":"concept:hrbrain_pool_code"}, + {"command":"hrbrain search employees","emitted":"department-name","expect":"dept-name","via":"override:department-name"}, + {"command":"hrbrain search employees","emitted":"job-title","expect":"position-name","via":"override:position-name"}, + {"command":"hrbrain search employees","emitted":"position-level","expect":"job-level","via":"override:job-level"}, + {"command":"hrbrain search employees","emitted":"dept-id","expect":"did-you-mean:blocked","via":"guard:department-name-vs-id"}, + {"command":"hrbrain search employees","emitted":"origin-json","expect":"did-you-mean:blocked","via":"guard:simple-vs-structured"}, + {"command":"hrbrain search employees","emitted":"pool","expect":"did-you-mean:ambiguous","via":"guard:pool-name-vs-code"}, + {"command":"hrbrain search employees-structured","emitted":"limit","expect":"page-size","via":"concept:pagination_size"}, + {"command":"hrbrain search employees-structured","emitted":"page-num","expect":"page","via":"concept:page_number"}, + {"command":"hrbrain search employees-structured","emitted":"filter-json","expect":"origin-json","via":"override:json-object"}, + {"command":"hrbrain search employees-structured","emitted":"criteria-json","expect":"origin-json","via":"override:json-object"}, + {"command":"hrbrain search employees-structured","emitted":"return-fields-json","expect":"fields","via":"override:json-array"}, + {"command":"hrbrain search employees-structured","emitted":"sort-by","expect":"order-by","via":"override:order-list"}, + {"command":"hrbrain search employees-structured","emitted":"keyword","expect":"did-you-mean:blocked","via":"guard:simple-filter-belongs-in-origin-json"}, + {"command":"hrbrain search employees-structured","emitted":"operator","expect":"did-you-mean:blocked","via":"guard:nested-json-member"}, + {"command":"hrbrain search employees-structured","emitted":"field","expect":"did-you-mean:ambiguous","via":"guard:local-fields-vs-nested-field"}, + {"command":"hrbrain search fields","emitted":"query","expect":"did-you-mean:blocked","via":"guard:no-search-input"}, + {"command":"hrbrain search fields","emitted":"origin-json","expect":"did-you-mean:blocked","via":"guard:fields-vs-execution"}, + {"command":"hrbrain search fields","emitted":"operator","expect":"did-you-mean:blocked","via":"guard:return-field-not-input"}, + {"command":"hrbrain search fields","emitted":"field","expect":"did-you-mean:ambiguous","via":"guard:returned-field-role"}, + {"command":"pat chmod","emitted":"product-code","expect":"product","via":"override:scoped-product"}, + {"command":"pat chmod","emitted":"product-codes","expect":"products","via":"override:scoped-products"}, + {"command":"pat chmod","emitted":"domain-code","expect":"domain","via":"override:scoped-domain"}, + {"command":"pat chmod","emitted":"domain-codes","expect":"domains","via":"override:scoped-domains"}, + {"command":"pat chmod","emitted":"grant-mode","expect":"grant-type","via":"override:scoped-grant-mode"}, + {"command":"pat chmod","emitted":"authorization-mode","expect":"grant-type","via":"override:scoped-grant-mode"}, + {"command":"pat chmod","emitted":"pat-session-id","expect":"session-id","via":"override:scoped-session"}, + {"command":"pat chmod","emitted":"authorization-session-id","expect":"session-id","via":"override:scoped-session"}, + {"command":"pat chmod","emitted":"recommended","expect":"recommend","via":"override:scoped-recommend"}, + {"command":"pat chmod","emitted":"scope","expect":"did-you-mean:blocked","via":"guard:flag-vs-positional"}, + {"command":"pat chmod","emitted":"all-scopes","expect":"did-you-mean:blocked","via":"guard:no-unbounded-expansion"}, + {"command":"pat chmod","emitted":"force","expect":"did-you-mean:blocked","via":"guard:no-confirmation-alias"}, + {"command":"pat chmod","emitted":"app-id","expect":"did-you-mean:blocked","via":"guard:pat-vs-app-permission"}, + {"command":"pat chmod","emitted":"open-browser","expect":"did-you-mean:blocked","via":"guard:authorization-vs-browser-policy"}, + {"command":"pat chmod","emitted":"agent-id","expect":"did-you-mean:ambiguous","via":"guard:agent-id-vs-agent-code"}, + {"command":"pat chmod","emitted":"session","expect":"did-you-mean:ambiguous","via":"guard:grant-type-vs-session-id"}, + {"command":"pat browser-policy","emitted":"open-browser","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat browser-policy","emitted":"browser-enabled","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat browser-policy","emitted":"allow-browser","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat browser-policy","emitted":"disable-browser","expect":"did-you-mean:blocked","via":"guard:requires-boolean-inversion"}, + {"command":"pat browser-policy","emitted":"scope","expect":"did-you-mean:blocked","via":"guard:policy-vs-authorization"}, + {"command":"pat browser-policy","emitted":"products","expect":"did-you-mean:blocked","via":"guard:policy-vs-plan"}, + {"command":"pat browser-policy","emitted":"app-id","expect":"did-you-mean:blocked","via":"guard:pat-vs-app-permission"}, + {"command":"pat browser-policy","emitted":"agent-id","expect":"did-you-mean:ambiguous","via":"guard:agent-id-vs-agent-code"}, + {"command":"pat browser-policy","emitted":"policy","expect":"did-you-mean:ambiguous","via":"guard:missing-boolean-role"}, + {"command":"pat +browser-policy","emitted":"open-browser","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat +browser-policy","emitted":"browser-enabled","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat +browser-policy","emitted":"allow-browser","expect":"enabled","via":"override:positive-boolean"}, + {"command":"pat +browser-policy","emitted":"disable-browser","expect":"did-you-mean:blocked","via":"guard:requires-boolean-inversion"}, + {"command":"pat +browser-policy","emitted":"scope","expect":"did-you-mean:blocked","via":"guard:policy-vs-authorization"}, + {"command":"pat +browser-policy","emitted":"products","expect":"did-you-mean:blocked","via":"guard:policy-vs-plan"}, + {"command":"pat +browser-policy","emitted":"app-id","expect":"did-you-mean:blocked","via":"guard:pat-vs-app-permission"}, + {"command":"pat +browser-policy","emitted":"agent-id","expect":"did-you-mean:ambiguous","via":"guard:agent-id-vs-agent-code"}, + {"command":"pat +browser-policy","emitted":"policy","expect":"did-you-mean:ambiguous","via":"guard:missing-boolean-role"} ] } } From 9ea19315e919626f6d3bc2f9ca6bc200a9aaa5b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Mon, 24 Aug 2026 23:04:45 +0800 Subject: [PATCH 19/33] fix(todo): classify upstream failures and validate inputs --- internal/app/runner.go | 2 + internal/app/server_failure_classifier.go | 55 ++++++-- .../app/server_failure_classifier_test.go | 121 ++++++++++++++++++ internal/helpers/todo.go | 91 ++++++++++--- .../helpers/todo_command_edges_more_test.go | 57 ++++++++- 5 files changed, 294 insertions(+), 32 deletions(-) diff --git a/internal/app/runner.go b/internal/app/runner.go index 36e78c1f..ed220d3c 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -740,6 +740,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, "mcp_tool_error", "MCP tool returned a business error; check tool parameters and refer to skill documentation.", invocation.CanonicalProduct, + invocation.Tool, diag, ) logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag) @@ -765,6 +766,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, "business_error", "The API returned a business-level error. Check required parameters and values.", invocation.CanonicalProduct, + invocation.Tool, diag, ) logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag) diff --git a/internal/app/server_failure_classifier.go b/internal/app/server_failure_classifier.go index 978a06c1..673e4cb9 100644 --- a/internal/app/server_failure_classifier.go +++ b/internal/app/server_failure_classifier.go @@ -20,18 +20,50 @@ import ( ) type serverFailureClass struct { - message string - reason string - origin string - stage string - hint string - actions []string + message string + reason string + origin string + stage string + hint string + actions []string + operation string + retryable *bool } -func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) { +func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) { code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode)) detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail)) text := strings.ToLower(strings.TrimSpace(message)) + combined := text + " " + detail + + if code == "999" && + (strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) { + classified := serverFailureClass{ + message: message, + reason: "upstream_internal_error", + origin: "dingtalk_api", + stage: "upstream_execution", + hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。", + actions: []string{ + "检查目标资源的当前状态,确认本次操作是否已经生效", + "状态未确认前不要直接重试写操作", + "持续失败时携带 Trace ID 和 Server Code 联系服务端排查", + }, + } + if strings.EqualFold(strings.TrimSpace(serverKey), "todo") && + strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") { + retryable := false + classified.operation = "todo/create_personal_todo" + classified.retryable = &retryable + classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。" + classified.actions = []string{ + "查询近期由自己创建的待办,核对标题、执行人和截止时间", + "确认没有创建成功后再重新提交", + "持续失败时携带 Trace ID 和 Server Code 联系服务端排查", + } + } + return classified, true + } if code == "NETWORK_ERROR" || strings.Contains(detail, "statuscode.unavailable") || @@ -74,6 +106,7 @@ func newServerFailureAPIError( fallbackReason string, fallbackHint string, serverKey string, + tool string, diag apperrors.ServerDiagnostics, ) error { opts := []apperrors.Option{ @@ -84,7 +117,7 @@ func newServerFailureAPIError( apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"), apperrors.WithServerDiag(diag), } - if classified, ok := classifyServerFailure(message, diag); ok { + if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok { message = classified.message opts = append(opts, apperrors.WithReason(classified.reason), @@ -93,6 +126,12 @@ func newServerFailureAPIError( apperrors.WithHint(classified.hint), apperrors.WithActions(classified.actions...), ) + if classified.operation != "" { + opts = append(opts, apperrors.WithOperation(classified.operation)) + } + if classified.retryable != nil { + opts = append(opts, apperrors.WithRetryable(*classified.retryable)) + } } return apperrors.NewAPI(message, opts...) } diff --git a/internal/app/server_failure_classifier_test.go b/internal/app/server_failure_classifier_test.go index df1a104d..d6e0aabf 100644 --- a/internal/app/server_failure_classifier_test.go +++ b/internal/app/server_failure_classifier_test.go @@ -34,6 +34,7 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable( "business_error", "check parameters", "im", + "list_conversations", apperrors.ServerDiagnostics{ TraceID: "trace-local", ServerErrorCode: "NETWORK_ERROR", @@ -66,6 +67,7 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t "business_error", "check parameters", "chat", + "send_message", apperrors.ServerDiagnostics{ServerErrorCode: "1001"}, ) var typed *apperrors.Error @@ -80,12 +82,74 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t } } +func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) { + serverSaysRetryable := true + err := newServerFailureAPIError( + "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)", + "business_error", + "The API returned a business-level error. Check required parameters and values.", + "todo", + "create_personal_todo", + apperrors.ServerDiagnostics{ + TraceID: "trace-todo-create", + ServerErrorCode: "999", + ServerRetryable: &serverSaysRetryable, + }, + ) + + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("error = %T, want *errors.Error", err) + } + if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" { + t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage) + } + if typed.Operation != "todo/create_personal_todo" { + t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation) + } + if typed.ExecutionStarted != nil { + t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted) + } + if !typed.RetryableSet || typed.Retryable { + t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable) + } + if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" { + t.Fatalf("diagnostics = %#v", typed.ServerDiag) + } + if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") { + t.Fatalf("hint = %q", typed.Hint) + } + for _, action := range typed.Actions { + if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") { + t.Fatalf("misleading action = %q", action) + } + } + + payload := multiProfileErrorPayload(err) + for key, want := range map[string]any{ + "reason": "upstream_internal_error", + "origin": "dingtalk_api", + "stage": "upstream_execution", + "retryable": false, + "trace_id": "trace-todo-create", + "server_error_code": "999", + } { + if got := payload[key]; got != want { + t.Errorf("payload[%q] = %#v, want %#v", key, got, want) + } + } + if _, ok := payload["execution_started"]; ok { + t.Fatalf("payload must keep execution_started unknown: %#v", payload) + } +} + func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) { err := newServerFailureAPIError( "business error: success=false", "business_error", "check parameters", "im", + "list_conversations", apperrors.ServerDiagnostics{}, ) var typed *apperrors.Error @@ -106,6 +170,7 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test "business_error", "check parameters", "im", + "list_conversations", apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"}, ) if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" { @@ -123,6 +188,7 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics( "business_error", "check parameters", "im", + "list_conversations", apperrors.ServerDiagnostics{ TraceID: "trace-multi", ServerErrorCode: "NETWORK_ERROR", @@ -224,3 +290,58 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted) } } + +func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + ID int `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode request: %v", err) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "jsonrpc": "2.0", + "id": request.ID, + "result": map[string]any{ + "structuredContent": map[string]any{ + "success": false, + "code": "999", + "trace_id": "trace-todo-replay", + "errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)", + }, + }, + }) + })) + defer server.Close() + + client := transport.NewClient(server.Client()) + client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")} + runner := &runtimeRunner{ + transport: client, + globalFlags: &GlobalFlags{Token: "local-test-token"}, + } + _, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{ + CanonicalProduct: "todo", + Tool: "create_personal_todo", + CanonicalPath: "todo.create_personal_todo", + Params: map[string]any{ + "PersonalTodoCreateVO": map[string]any{ + "subject": "fixture", + "executorIds": []string{"user-1"}, + }, + }, + }) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err) + } + if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" { + t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation) + } + if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil { + t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted) + } + if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" { + t.Fatalf("diagnostics = %#v", typed.ServerDiag) + } +} diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index 5332513a..fd4bad53 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -96,7 +96,10 @@ func newTodoCommand() *cobra.Command { return err } executorsStr := mustGetFlag(cmd, "executors") - executorIds := parseExecutorIds(executorsStr) + executorIds, err := parseRequiredTodoIDs("executors", executorsStr) + if err != nil { + return err + } toolArgs := map[string]any{ "PersonalTodoCreateVO": map[string]any{ "subject": flagOrFallback(cmd, "title", "subject", "content"), @@ -111,9 +114,11 @@ func newTodoCommand() *cobra.Command { toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms } if v, _ := cmd.Flags().GetString("priority"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n + n, err := parseTodoPriority(v) + if err != nil { + return err } + toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n } if v, _ := cmd.Flags().GetString("recurrence"); v != "" { toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v @@ -154,8 +159,8 @@ func newTodoCommand() *cobra.Command { }, Parameters: []contract.ParamDecl{ {Name: "due", Property: "PersonalTodoCreateVO.dueTime"}, - {Name: "executors", Property: "PersonalTodoCreateVO.executorIds"}, - {Name: "priority", Property: "PersonalTodoCreateVO.priority"}, + {Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"}, + {Name: "priority", Property: "PersonalTodoCreateVO.priority", Enum: []string{"10", "20", "30", "40"}}, {Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"}, {Name: "title", Property: "PersonalTodoCreateVO.subject"}, }, @@ -190,7 +195,10 @@ func newTodoCommand() *cobra.Command { } } executorsStr := mustGetFlag(cmd, "executors") - executorIds := parseExecutorIds(executorsStr) + executorIds, err := parseRequiredTodoIDs("executors", executorsStr) + if err != nil { + return err + } toolArgs := map[string]any{ "PersonalTodoCreateVO": map[string]any{ "subject": flagOrFallback(cmd, "title", "subject", "content"), @@ -206,9 +214,11 @@ func newTodoCommand() *cobra.Command { toolArgs["PersonalTodoCreateVO"].(map[string]any)["dueTime"] = ms } if v, _ := cmd.Flags().GetString("priority"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n + n, err := parseTodoPriority(v) + if err != nil { + return err } + toolArgs["PersonalTodoCreateVO"].(map[string]any)["priority"] = n } if v, _ := cmd.Flags().GetString("recurrence"); v != "" { toolArgs["PersonalTodoCreateVO"].(map[string]any)["recurrence"] = v @@ -249,9 +259,9 @@ func newTodoCommand() *cobra.Command { }, Parameters: []contract.ParamDecl{ {Name: "due", Property: "PersonalTodoCreateVO.dueTime"}, - {Name: "executors", Property: "PersonalTodoCreateVO.executorIds"}, + {Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"}, {Name: "parent-id", Property: "PersonalTodoCreateVO.parentId"}, - {Name: "priority", Property: "PersonalTodoCreateVO.priority"}, + {Name: "priority", Property: "PersonalTodoCreateVO.priority", Enum: []string{"10", "20", "30", "40"}}, {Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"}, {Name: "title", Property: "PersonalTodoCreateVO.subject"}, }, @@ -376,9 +386,11 @@ func newTodoCommand() *cobra.Command { inner["dueTime"] = ms } if v, _ := cmd.Flags().GetString("priority"); v != "" { - if n, err := strconv.Atoi(v); err == nil { - inner["priority"] = n + n, err := parseTodoPriority(v) + if err != nil { + return err } + inner["priority"] = n } if v, _ := cmd.Flags().GetString("done"); v != "" { inner["isDone"] = v == "true" @@ -422,7 +434,7 @@ func newTodoCommand() *cobra.Command { Parameters: []contract.ParamDecl{ {Name: "done", Property: "TodoUpdateRequest.isDone"}, {Name: "due", Property: "TodoUpdateRequest.dueTime"}, - {Name: "priority", Property: "TodoUpdateRequest.priority"}, + {Name: "priority", Property: "TodoUpdateRequest.priority", Enum: []string{"10", "20", "30", "40"}}, {Name: "task-id", Property: "TodoUpdateRequest.taskId"}, {Name: "title", Property: "TodoUpdateRequest.subject"}, }, @@ -630,7 +642,10 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 return err } executorsStr := mustGetFlag(cmd, "executors") - executorIds := parseExecutorIds(executorsStr) + executorIds, err := parseRequiredTodoIDs("executors", executorsStr) + if err != nil { + return err + } return callMCPTool("add_task_executors", map[string]any{ "todoExecutorsAddRequest": map[string]any{ "taskId": mustGetFlag(cmd, "task-id"), @@ -672,7 +687,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 }, }, Parameters: []contract.ParamDecl{ - {Name: "executors", Property: "todoExecutorsAddRequest.executorIds"}, + {Name: "executors", Property: "todoExecutorsAddRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"}, {Name: "task-id", Property: "todoExecutorsAddRequest.taskId"}, }, }, @@ -688,7 +703,10 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 return err } executorsStr := mustGetFlag(cmd, "executors") - executorIds := parseExecutorIds(executorsStr) + executorIds, err := parseRequiredTodoIDs("executors", executorsStr) + if err != nil { + return err + } return callMCPTool("remove_task_executors", map[string]any{ "todoExecutorsRemoveRequest": map[string]any{ "taskId": mustGetFlag(cmd, "task-id"), @@ -729,7 +747,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 }, }, Parameters: []contract.ParamDecl{ - {Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds"}, + {Name: "executors", Property: "todoExecutorsRemoveRequest.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"}, {Name: "task-id", Property: "todoExecutorsRemoveRequest.taskId"}, }, }, @@ -1189,14 +1207,14 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 } todoTaskCreateCmd.Flags().String("title", "", "待办标题 (必填)") - todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)") + todoTaskCreateCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)") todoTaskCreateCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)") todoTaskCreateCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急") todoTaskCreateCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1") todoTaskCreateSubCmd.Flags().String("parent-id", "", "父待办任务 ID (必填)") todoTaskCreateSubCmd.Flags().String("title", "", "子待办标题 (必填)") - todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)") + todoTaskCreateSubCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)") todoTaskCreateSubCmd.Flags().String("due", "", "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)") todoTaskCreateSubCmd.Flags().String("priority", "", "优先级: 10低/20普通/30较高/40紧急") todoTaskCreateSubCmd.Flags().String("recurrence", "", "循环待办 (需先设置 --due); 格式: DTSTART:20260320T100000Z\\nRRULE:FREQ=DAILY;INTERVAL=1") @@ -1235,9 +1253,9 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 _ = todoTaskCmd.PersistentFlags().MarkHidden("remind-at") todoTaskAddExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)") - todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)") + todoTaskAddExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)") todoTaskRemoveExecutorCmd.Flags().String("task-id", "", "待办任务 ID (必填)") - todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表 (必填)") + todoTaskRemoveExecutorCmd.Flags().String("executors", "", "执行者 userId 列表,逗号分隔且至少一个非空值 (必填)") todoTaskAddParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)") todoTaskAddParticipantCmd.Flags().String("participants", "", "参与人 userId 列表 (必填)") todoTaskRemoveParticipantCmd.Flags().String("task-id", "", "待办任务 ID (必填)") @@ -1893,6 +1911,37 @@ func parseExecutorIds(s string) []string { return ids } +func parseRequiredTodoIDs(flagName, value string) ([]string, error) { + ids := parseExecutorIds(value) + if len(ids) > 0 { + return ids, nil + } + return nil, apperrors.NewValidation( + fmt.Sprintf("--%s 至少需要一个非空 userId", flagName), + apperrors.WithReason("invalid_"+strings.ReplaceAll(flagName, "-", "_")), + apperrors.WithHint(fmt.Sprintf("使用 --%s [,...] 指定至少一个执行人", flagName)), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + ) +} + +func parseTodoPriority(value string) (int, error) { + priority, err := strconv.Atoi(strings.TrimSpace(value)) + if err == nil { + switch priority { + case 10, 20, 30, 40: + return priority, nil + } + } + return 0, apperrors.NewValidation( + "--priority 仅接受 10/20/30/40", + apperrors.WithReason("invalid_priority"), + apperrors.WithHint("优先级映射:10=低、20=普通、30=较高、40=紧急"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + ) +} + type todoLocalFileMeta struct { LocalPath string FileName string diff --git a/internal/helpers/todo_command_edges_more_test.go b/internal/helpers/todo_command_edges_more_test.go index fc74a977..297b9986 100644 --- a/internal/helpers/todo_command_edges_more_test.go +++ b/internal/helpers/todo_command_edges_more_test.go @@ -8,6 +8,8 @@ import ( "path/filepath" "strings" "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" ) func executeTodoEdge(t *testing.T, caller *scriptedToolCaller, args ...string) error { @@ -44,9 +46,12 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) { errorCases := [][]string{ {"task", "create", "--title", "x", "--executors", "u", "--remind-at", validDate}, {"task", "create", "--title", "x", "--executors", "u", "--due", "bad"}, + {"task", "create", "--title", "x", "--executors", "u", "--priority", "bad"}, + {"task", "create", "--title", "x", "--executors", "u", "--priority", "25"}, {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "abc"}, {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--remind-at", validDate}, {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--due", "bad"}, + {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"}, {"task", "list", "--role-types", "invalid"}, {"task", "list", "--plan-finish-date-start", "bad"}, {"task", "list", "--plan-finish-date-end", "bad"}, @@ -59,9 +64,8 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) { validCases := [][]string{ {"task", "create", "--title", "x", "--executors", "u1,u2", "--due", validDate, "--priority", "40", "--recurrence", "daily"}, - {"task", "create", "--subject", "x", "--executors", "u", "--priority", "bad"}, + {"task", "create", "--subject", "x", "--executors", "u", "--priority", "20"}, {"task", "create-sub", "--content", "x", "--executors", "u", "--parent-id", "1", "--due", validDate, "--priority", "40", "--recurrence", "daily"}, - {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--priority", "bad"}, {"task", "list", "--size", "bad"}, {"task", "list", "--status", "true", "--priority", "40,bad", "--role-types", "creator,executor", "--plan-finish-date-start", validDate, "--plan-finish-date-end", validDate}, } @@ -75,6 +79,53 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) { } } +func TestCrossPlatformCoverageTodoRejectsEmptyExecutorCSVAndInvalidPriorityBeforeCall(t *testing.T) { + tests := []struct { + name string + reason string + args []string + }{ + {name: "create empty executors", reason: "invalid_executors", args: []string{"task", "create", "--title", "x", "--executors", ",,,"}}, + {name: "create-sub empty executors", reason: "invalid_executors", args: []string{"task", "create-sub", "--parent-id", "1", "--title", "x", "--executors", " , , "}}, + {name: "add empty executors", reason: "invalid_executors", args: []string{"task", "add-executor", "--task-id", "1", "--executors", ",,,"}}, + {name: "remove empty executors", reason: "invalid_executors", args: []string{"task", "remove-executor", "--task-id", "1", "--executors", ",,,"}}, + {name: "create nonnumeric priority", reason: "invalid_priority", args: []string{"task", "create", "--title", "x", "--executors", "u", "--priority", "normal"}}, + {name: "create unsupported priority", reason: "invalid_priority", args: []string{"task", "create", "--title", "x", "--executors", "u", "--priority", "25"}}, + {name: "create-sub unsupported priority", reason: "invalid_priority", args: []string{"task", "create-sub", "--parent-id", "1", "--title", "x", "--executors", "u", "--priority", "25"}}, + {name: "update unsupported priority", reason: "invalid_priority", args: []string{"task", "update", "--task-id", "1", "--priority", "25"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeTodoEdge(t, caller, test.args...) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != test.reason { + t.Fatalf("error = %T %v, want reason %q", err, err, test.reason) + } + if typed.ExecutionStarted == nil || *typed.ExecutionStarted { + t.Fatalf("execution_started = %v, want false", typed.ExecutionStarted) + } + if caller.calls != 0 { + t.Fatalf("invalid input made %d MCP call(s)", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageTodoCreateAcceptsNormalPriority(t *testing.T) { + caller := &scriptedToolCaller{} + if err := executeTodoEdge(t, caller, "task", "create", "--title", "x", "--executors", "u", "--priority", "20"); err != nil { + t.Fatalf("create priority 20: %v", err) + } + if caller.calls != 1 || caller.tool != "create_personal_todo" { + t.Fatalf("calls = %d tool = %q", caller.calls, caller.tool) + } + request, ok := caller.args["PersonalTodoCreateVO"].(map[string]any) + if !ok || request["priority"] != 20 { + t.Fatalf("request = %#v, want priority 20", caller.args) + } +} + func TestCrossPlatformCoverageTodoSimpleCommandValidationAndSuccessEdges(t *testing.T) { errorCases := [][]string{ {"task", "create"}, @@ -143,6 +194,7 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) { errorCases := [][]string{ {"task", "update", "--task-id", "1", "--remind-at", validDate}, {"task", "update", "--task-id", "1", "--due", "bad"}, + {"task", "update", "--task-id", "1", "--priority", "bad"}, {"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", "bad"}, {"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[{"baseTime":"customTime","reminderTimeStamp":"bad"}]`}, {"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `not-json`}, @@ -155,7 +207,6 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) { } validCases := [][]string{ {"task", "update", "--task-id", "1", "--title", "new", "--due", validDate, "--priority", "40", "--done", "true"}, - {"task", "update", "--task-id", "1", "--priority", "bad"}, {"task", "add-reminder", "--task-id", "1", "--base-time", "dueTime", "--due-date-offset", "-10"}, {"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", validDate}, } From 26253a4eebe5d14d0b5702d707b271f6c05ad093 Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 24 Aug 2026 23:11:30 +0800 Subject: [PATCH 20/33] fix(policy): allow audited pre-tag beta amendments --- scripts/policy/check-release-fragments.sh | 218 ++++++++++++++++++---- test/scripts/changelog_pr_gate_test.go | 73 ++++++++ 2 files changed, 259 insertions(+), 32 deletions(-) diff --git a/scripts/policy/check-release-fragments.sh b/scripts/policy/check-release-fragments.sh index a617831e..27c20c9e 100755 --- a/scripts/policy/check-release-fragments.sh +++ b/scripts/policy/check-release-fragments.sh @@ -19,17 +19,172 @@ trap cleanup EXIT HUP INT TERM git -C "$ROOT" diff --no-ext-diff --find-renames --name-status "$merge_base" "$head" >"$tmp_root/status" +normalize_notes() { + awk ' + /^[[:space:]]*$/ && !started { next } + { started = 1; lines[++count] = $0 } + END { + while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count-- + for (line_no = 1; line_no <= count; line_no++) print lines[line_no] + } + ' "$1" +} + +extract_release_notes() { + git -C "$ROOT" show "$1:CHANGELOG.md" | + awk -v heading="## [$2] - " ' + index($0, heading) == 1 { found = 1; next } + found && /^## / { exit } + found { print } + ' +} + +release_heading() { + git -C "$ROOT" show "$1:CHANGELOG.md" | + awk -v heading="## [$2] - " 'index($0, heading) == 1 { print }' +} + +changelog_without_release_notes() { + git -C "$ROOT" show "$1:CHANGELOG.md" | + awk -v heading="## [$2] - " ' + index($0, heading) == 1 { print; skipped = 1; next } + skipped && /^## / { skipped = 0 } + !skipped { print } + ' +} + +# A pre-tag beta amendment must preserve the original seal verbatim except for +# appending newly archived fragment bodies to their existing categories. These +# helpers canonicalize the renderer's category format before comparing it. +canonicalize_fragment_notes() { + awk ' + BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" } + function trim(value) { + sub(/^[\n]+/, "", value) + sub(/[\n]+$/, "", value) + return value + } + function flush() { + if (category == "") return + value = trim(body) + if (value == "" || seen[category]++) invalid = 1 + blocks[category] = value + category = "" + body = "" + } + /^### / { + flush() + candidate = substr($0, 5) + if (candidate !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) { + invalid = 1 + next + } + category = candidate + next + } + { + if (category == "") { + if ($0 !~ /^[[:space:]]*$/) invalid = 1 + next + } + body = body $0 "\n" + } + END { + flush() + if (invalid) exit 1 + for (i = 1; i <= 6; i++) { + category = order[i] + if (category in blocks) printf "### %s\n\n%s\n\n", category, blocks[category] + } + } + ' "$1" +} + +merge_canonical_fragment_notes() { + awk ' + BEGIN { order[1] = "Added"; order[2] = "Changed"; order[3] = "Deprecated"; order[4] = "Removed"; order[5] = "Fixed"; order[6] = "Security" } + function trim(value) { + sub(/^[\n]+/, "", value) + sub(/[\n]+$/, "", value) + return value + } + function flush() { + if (category == "") return + value = trim(body) + if (value == "") invalid = 1 + if (part == 1) before[category] = value + else if (part == 2) addition[category] = value + else invalid = 1 + category = "" + body = "" + } + FNR == 1 { + if (started) flush() + started = 1 + part++ + } + /^### / { + flush() + category = substr($0, 5) + if (category !~ /^(Added|Changed|Deprecated|Removed|Fixed|Security)$/) invalid = 1 + next + } + { body = body $0 "\n" } + END { + flush() + if (invalid || part != 2) exit 1 + for (i = 1; i <= 6; i++) { + category = order[i] + if (!(category in before) && !(category in addition)) continue + printf "### %s\n\n", category + if (category in before) printf "%s\n\n", before[category] + if (category in addition) printf "%s\n\n", addition[category] + } + } + ' "$1" "$2" +} + archive_changed=false if awk -F '\t' '{ for (field = 2; field <= NF; field++) if ($field ~ /^\.changes\/released\//) found = 1 } END { exit !found }' "$tmp_root/status"; then archive_changed=true fi if [ "$archive_changed" = true ]; then - release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')" - [ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { - printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section' >&2 - exit 1 - } + new_release_version="$(git -C "$ROOT" diff --no-ext-diff --unified=0 "$merge_base" "$head" -- CHANGELOG.md | sed -n 's/^+## \[\([0-9][0-9.]*\(-beta\.[1-9][0-9]*\)\{0,1\}\)\] - .*/\1/p')" + new_release_version_count="$(printf '%s\n' "$new_release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" + archival_mode=seal + if [ "$new_release_version_count" -eq 1 ]; then + release_version="$new_release_version" + else + # An amendment keeps the existing heading. Derive the one allowed target + # from the literal archive directory, then verify every move below. + release_version="$(awk -F '\t' '$1 == "R100" && NF == 3 && $3 ~ /^\.changes\/released\// { path = $3; sub(/^\.changes\/released\//, "", path); sub(/\/.*/, "", path); print path }' "$tmp_root/status" | sort -u)" + [ "$(printf '%s\n' "$release_version" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { + printf '%s\n' 'error: release-fragment archival requires exactly one newly added versioned CHANGELOG section or one untagged beta amendment target' >&2 + exit 1 + } + printf '%s\n' "$release_version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]*$' || { + printf '%s\n' 'error: only an existing beta CHANGELOG section may receive a pre-tag fragment amendment' >&2 + exit 1 + } + base_heading="$(release_heading "$merge_base" "$release_version")" + head_heading="$(release_heading "$head" "$release_version")" + [ "$(printf '%s\n' "$base_heading" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] && [ "$base_heading" = "$head_heading" ] || { + printf '%s\n' 'error: beta fragment amendment requires one unchanged pre-existing CHANGELOG heading' >&2 + exit 1 + } + changelog_without_release_notes "$merge_base" "$release_version" >"$tmp_root/base-changelog-without-amendment" + changelog_without_release_notes "$head" "$release_version" >"$tmp_root/head-changelog-without-amendment" + if ! cmp -s "$tmp_root/base-changelog-without-amendment" "$tmp_root/head-changelog-without-amendment"; then + printf '%s\n' 'error: beta fragment amendment may change only its existing beta CHANGELOG section' >&2 + exit 1 + fi + if git -C "$ROOT" rev-parse --verify --quiet "refs/tags/v$release_version^{commit}" >/dev/null; then + printf 'error: beta fragment amendment is forbidden after tag v%s exists\n' "$release_version" >&2 + exit 1 + fi + archival_mode=beta-amendment + fi # The archive directory is matched as a literal prefix, never as a regex: # interpolating the version into one would make `.` match any character, so # `1.0.1-beta.1` would also admit `.changes/released/1x0x1-betaX1/` and break @@ -47,7 +202,7 @@ if [ "$archive_changed" = true ]; then { invalid = 1 } END { exit !(changelog && moved > 0 && !invalid) } ' "$tmp_root/status"; then - printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/.md to .changes/released//.md in the matching release-seal PR' >&2 + printf '%s\n' 'error: release fragments must be unchanged R100 moves from .changes/.md to .changes/released//.md in the matching release-seal or untagged beta-amendment PR' >&2 exit 1 fi source_changes="$tmp_root/source-changes" @@ -63,17 +218,25 @@ if [ "$archive_changed" = true ]; then esac done "$ROOT/scripts/release/render-release-fragments.sh" "$source_changes" >"$tmp_root/expected-notes" - git -C "$ROOT" show "$head:CHANGELOG.md" | - awk -v heading="## [$release_version] - " ' - index($0, heading) == 1 { found = 1; next } - found && /^## / { exit } - found { print } - ' >"$tmp_root/actual-section" - case "$release_version" in - *-beta.*) - cp "$tmp_root/actual-section" "$tmp_root/actual-notes" - ;; - *) + if [ "$archival_mode" = beta-amendment ]; then + extract_release_notes "$merge_base" "$release_version" >"$tmp_root/base-notes" + extract_release_notes "$head" "$release_version" >"$tmp_root/head-notes" + if ! canonicalize_fragment_notes "$tmp_root/base-notes" >"$tmp_root/base-notes.canonical" || + ! canonicalize_fragment_notes "$tmp_root/expected-notes" >"$tmp_root/amendment-notes.canonical" || + ! merge_canonical_fragment_notes "$tmp_root/base-notes.canonical" "$tmp_root/amendment-notes.canonical" >"$tmp_root/expected-notes.merged" || + ! canonicalize_fragment_notes "$tmp_root/head-notes" >"$tmp_root/actual-notes.canonical"; then + printf '%s\n' 'error: beta fragment amendment requires canonical rendered fragment categories' >&2 + exit 1 + fi + normalize_notes "$tmp_root/expected-notes.merged" >"$tmp_root/expected-notes.normalized" + normalize_notes "$tmp_root/actual-notes.canonical" >"$tmp_root/actual-notes.normalized" + else + extract_release_notes "$head" "$release_version" >"$tmp_root/actual-section" + case "$release_version" in + *-beta.*) + cp "$tmp_root/actual-section" "$tmp_root/actual-notes" + ;; + *) stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")" [ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2 @@ -88,22 +251,13 @@ if [ "$archive_changed" = true ]; then found { print } /^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 } ' "$tmp_root/actual-section" >"$tmp_root/actual-notes" - ;; - esac - normalize_notes() { - awk ' - /^[[:space:]]*$/ && !started { next } - { started = 1; lines[++count] = $0 } - END { - while (count > 0 && lines[count] ~ /^[[:space:]]*$/) count-- - for (line_no = 1; line_no <= count; line_no++) print lines[line_no] - } - ' "$1" - } - normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized" - normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized" + ;; + esac + normalize_notes "$tmp_root/expected-notes" >"$tmp_root/expected-notes.normalized" + normalize_notes "$tmp_root/actual-notes" >"$tmp_root/actual-notes.normalized" + fi if ! cmp -s "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized"; then - printf '%s\n' 'error: release-seal CHANGELOG section does not exactly match the rendered active release fragments' >&2 + printf '%s\n' 'error: release CHANGELOG section does not exactly match the rendered active release fragments' >&2 diff -u "$tmp_root/expected-notes.normalized" "$tmp_root/actual-notes.normalized" >&2 || true exit 1 fi diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index a787d307..1585d6ae 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -351,6 +351,79 @@ This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable. } } +func TestReleaseFragmentPolicyAcceptsOnlyUntaggedCanonicalBetaAmendments(t *testing.T) { + newAmendmentRepo := func(t *testing.T) (*changelogGateRepo, string) { + t.Helper() + repo := newChangelogGateRepo(t) + sealBase := repo.sealFragmentInto(t, "1.0.1-beta.1") + if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil { + t.Fatalf("release fragment policy rejected initial beta seal: %v\noutput:\n%s", err, output) + } + changelogGateWrite(t, repo.root, ".changes/1235-sheet.md", "---\ncategory: Added\n---\n\n- Sheet accepts local float image files.\n", 0o644) + repo.commit(t, "merge post-seal beta fragment") + return repo, strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD")) + } + + stageCanonicalAmendment := func(t *testing.T, repo *changelogGateRepo) { + t.Helper() + changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog + +## [Unreleased] + +## [1.0.1-beta.1] - 2026-07-17 + +### Added + +- Chat reply mentions. + +- Sheet accepts local float image files. + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. +`, 0o644) + archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1-beta.1") + if err := os.Rename(filepath.Join(repo.root, ".changes", "1235-sheet.md"), filepath.Join(archiveDir, "1235-sheet.md")); err != nil { + t.Fatalf("Rename beta amendment fragment: %v", err) + } + repo.commit(t, "amend untagged beta release notes") + } + + t.Run("accepts exact pre-tag merge", func(t *testing.T) { + repo, amendmentBase := newAmendmentRepo(t) + stageCanonicalAmendment(t, repo) + + if output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD"); err != nil { + t.Fatalf("release fragment policy rejected canonical beta amendment: %v\noutput:\n%s", err, output) + } + }) + + t.Run("rejects tagged beta", func(t *testing.T) { + repo, amendmentBase := newAmendmentRepo(t) + stageCanonicalAmendment(t, repo) + changelogGateGit(t, repo.root, "tag", "v1.0.1-beta.1") + + output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD") + if err == nil || !strings.Contains(output, "forbidden after tag v1.0.1-beta.1 exists") { + t.Fatalf("tagged beta amendment passed: err=%v\noutput:\n%s", err, output) + } + }) + + t.Run("rejects rewritten sealed prose", func(t *testing.T) { + repo, amendmentBase := newAmendmentRepo(t) + stageCanonicalAmendment(t, repo) + changelogGateWrite(t, repo.root, "CHANGELOG.md", strings.Replace(changelogGateSealedRelease, "Chat reply mentions.", "Rewritten sealed note.", 1), 0o644) + repo.commit(t, "rewrite sealed beta prose") + + output, err := repo.runFragmentPolicy(t, amendmentBase, "HEAD") + if err == nil || !strings.Contains(output, "does not exactly match") { + t.Fatalf("rewritten beta amendment passed: err=%v\noutput:\n%s", err, output) + } + }) +} + func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) { t.Run("invalid active fragment", func(t *testing.T) { repo := newChangelogGateRepo(t) From a4813f78bc6676e9507fe3d500afb8d038ac68a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E8=B0=A8?= <2940480744@qq.com> Date: Mon, 24 Aug 2026 23:33:43 +0800 Subject: [PATCH 21/33] fix(todo): preserve priority schema compatibility --- internal/helpers/todo.go | 15 +++---- .../helpers/todo_command_edges_more_test.go | 40 ++++++++++++------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index fd4bad53..641b17b1 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -160,7 +160,7 @@ func newTodoCommand() *cobra.Command { Parameters: []contract.ParamDecl{ {Name: "due", Property: "PersonalTodoCreateVO.dueTime"}, {Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"}, - {Name: "priority", Property: "PersonalTodoCreateVO.priority", Enum: []string{"10", "20", "30", "40"}}, + {Name: "priority", Property: "PersonalTodoCreateVO.priority"}, {Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"}, {Name: "title", Property: "PersonalTodoCreateVO.subject"}, }, @@ -261,7 +261,7 @@ func newTodoCommand() *cobra.Command { {Name: "due", Property: "PersonalTodoCreateVO.dueTime"}, {Name: "executors", Property: "PersonalTodoCreateVO.executorIds", Description: "执行者 userId 列表;逗号分隔,解析后至少包含一个非空值"}, {Name: "parent-id", Property: "PersonalTodoCreateVO.parentId"}, - {Name: "priority", Property: "PersonalTodoCreateVO.priority", Enum: []string{"10", "20", "30", "40"}}, + {Name: "priority", Property: "PersonalTodoCreateVO.priority"}, {Name: "recurrence", Property: "PersonalTodoCreateVO.recurrence"}, {Name: "title", Property: "PersonalTodoCreateVO.subject"}, }, @@ -434,7 +434,7 @@ func newTodoCommand() *cobra.Command { Parameters: []contract.ParamDecl{ {Name: "done", Property: "TodoUpdateRequest.isDone"}, {Name: "due", Property: "TodoUpdateRequest.dueTime"}, - {Name: "priority", Property: "TodoUpdateRequest.priority", Enum: []string{"10", "20", "30", "40"}}, + {Name: "priority", Property: "TodoUpdateRequest.priority"}, {Name: "task-id", Property: "TodoUpdateRequest.taskId"}, {Name: "title", Property: "TodoUpdateRequest.subject"}, }, @@ -1928,15 +1928,12 @@ func parseRequiredTodoIDs(flagName, value string) ([]string, error) { func parseTodoPriority(value string) (int, error) { priority, err := strconv.Atoi(strings.TrimSpace(value)) if err == nil { - switch priority { - case 10, 20, 30, 40: - return priority, nil - } + return priority, nil } return 0, apperrors.NewValidation( - "--priority 仅接受 10/20/30/40", + "--priority 必须是整数", apperrors.WithReason("invalid_priority"), - apperrors.WithHint("优先级映射:10=低、20=普通、30=较高、40=紧急"), + apperrors.WithHint("常用优先级映射:10=低、20=普通、30=较高、40=紧急"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false), ) diff --git a/internal/helpers/todo_command_edges_more_test.go b/internal/helpers/todo_command_edges_more_test.go index 297b9986..336e48d7 100644 --- a/internal/helpers/todo_command_edges_more_test.go +++ b/internal/helpers/todo_command_edges_more_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "testing" @@ -47,7 +48,6 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) { {"task", "create", "--title", "x", "--executors", "u", "--remind-at", validDate}, {"task", "create", "--title", "x", "--executors", "u", "--due", "bad"}, {"task", "create", "--title", "x", "--executors", "u", "--priority", "bad"}, - {"task", "create", "--title", "x", "--executors", "u", "--priority", "25"}, {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "abc"}, {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--remind-at", validDate}, {"task", "create-sub", "--title", "x", "--executors", "u", "--parent-id", "1", "--due", "bad"}, @@ -79,7 +79,7 @@ func TestCrossPlatformCoverageTodoCreateAndListCommandEdges(t *testing.T) { } } -func TestCrossPlatformCoverageTodoRejectsEmptyExecutorCSVAndInvalidPriorityBeforeCall(t *testing.T) { +func TestCrossPlatformCoverageTodoRejectsEmptyExecutorCSVAndNonNumericPriorityBeforeCall(t *testing.T) { tests := []struct { name string reason string @@ -90,9 +90,6 @@ func TestCrossPlatformCoverageTodoRejectsEmptyExecutorCSVAndInvalidPriorityBefor {name: "add empty executors", reason: "invalid_executors", args: []string{"task", "add-executor", "--task-id", "1", "--executors", ",,,"}}, {name: "remove empty executors", reason: "invalid_executors", args: []string{"task", "remove-executor", "--task-id", "1", "--executors", ",,,"}}, {name: "create nonnumeric priority", reason: "invalid_priority", args: []string{"task", "create", "--title", "x", "--executors", "u", "--priority", "normal"}}, - {name: "create unsupported priority", reason: "invalid_priority", args: []string{"task", "create", "--title", "x", "--executors", "u", "--priority", "25"}}, - {name: "create-sub unsupported priority", reason: "invalid_priority", args: []string{"task", "create-sub", "--parent-id", "1", "--title", "x", "--executors", "u", "--priority", "25"}}, - {name: "update unsupported priority", reason: "invalid_priority", args: []string{"task", "update", "--task-id", "1", "--priority", "25"}}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -112,18 +109,31 @@ func TestCrossPlatformCoverageTodoRejectsEmptyExecutorCSVAndInvalidPriorityBefor } } -func TestCrossPlatformCoverageTodoCreateAcceptsNormalPriority(t *testing.T) { - caller := &scriptedToolCaller{} - if err := executeTodoEdge(t, caller, "task", "create", "--title", "x", "--executors", "u", "--priority", "20"); err != nil { - t.Fatalf("create priority 20: %v", err) +func TestCrossPlatformCoverageTodoCreateAcceptsNumericPriority(t *testing.T) { + for _, priority := range []string{"20", "25"} { + t.Run(priority, func(t *testing.T) { + caller := &scriptedToolCaller{} + if err := executeTodoEdge(t, caller, "task", "create", "--title", "x", "--executors", "u", "--priority", priority); err != nil { + t.Fatalf("create priority %s: %v", priority, err) + } + if caller.calls != 1 || caller.tool != "create_personal_todo" { + t.Fatalf("calls = %d tool = %q", caller.calls, caller.tool) + } + request, ok := caller.args["PersonalTodoCreateVO"].(map[string]any) + if !ok || request["priority"] != mustTodoAtoi(t, priority) { + t.Fatalf("request = %#v, want priority %s", caller.args, priority) + } + }) } - if caller.calls != 1 || caller.tool != "create_personal_todo" { - t.Fatalf("calls = %d tool = %q", caller.calls, caller.tool) - } - request, ok := caller.args["PersonalTodoCreateVO"].(map[string]any) - if !ok || request["priority"] != 20 { - t.Fatalf("request = %#v, want priority 20", caller.args) +} + +func mustTodoAtoi(t *testing.T, value string) int { + t.Helper() + parsed, err := strconv.Atoi(value) + if err != nil { + t.Fatalf("parse %q: %v", value, err) } + return parsed } func TestCrossPlatformCoverageTodoSimpleCommandValidationAndSuccessEdges(t *testing.T) { From f684c412ea6e316a5c5fa789410692c56359324e Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 24 Aug 2026 23:37:21 +0800 Subject: [PATCH 22/33] docs: amend v1.0.60-beta.2 release notes --- .../1.0.60-beta.2}/sheet-float-image-local-file.md | 0 CHANGELOG.md | 2 ++ 2 files changed, 2 insertions(+) rename .changes/{ => released/1.0.60-beta.2}/sheet-float-image-local-file.md (100%) diff --git a/.changes/sheet-float-image-local-file.md b/.changes/released/1.0.60-beta.2/sheet-float-image-local-file.md similarity index 100% rename from .changes/sheet-float-image-local-file.md rename to .changes/released/1.0.60-beta.2/sheet-float-image-local-file.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef0a3b1..145012a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,8 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th - **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets. +- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow. + ### Changed - **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts. From ed951ff0e13c00a5dd2c4f3de21e1c0f564785d2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:57:43 +0000 Subject: [PATCH 23/33] chore: update beta formula for v1.0.60-beta.2 [skip ci] --- Formula/dingtalk-workspace-cli-beta.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 28413205..2a651910 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -1,33 +1,33 @@ class DingtalkWorkspaceCliBeta < Formula desc "Automate DingTalk workspace tasks from the terminal (beta channel)" homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli" - version "1.0.60-beta.1" + version "1.0.60-beta.2" license "Apache-2.0" keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli" on_macos do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz" - sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz" + sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz" - sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz" + sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034" end end on_linux do if Hardware::CPU.arm? - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz" - sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz" + sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad" else - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz" - sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz" + sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef" end end resource "skills" do - url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip" - sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87" + url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip" + sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5" end def install From ea4a4639562129d22644f6333c3af7209942956e Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 24 Aug 2026 11:17:24 +0800 Subject: [PATCH 24/33] fix: add bounded command typo guidance --- .changes/command-typo-guidance.md | 5 + internal/app/mcp_url_command.go | 2 + internal/app/root.go | 25 +- internal/app/root_help_test.go | 6 +- internal/helpers/agoal.go | 16 +- internal/helpers/aisearch.go | 1 + internal/helpers/aitable.go | 50 ++-- internal/helpers/attendance.go | 46 ++-- internal/helpers/calendar.go | 59 ++--- .../helpers/calendar_coverage_more_test.go | 4 +- internal/helpers/chat.go | 33 +-- internal/helpers/chat/toolbar.go | 6 +- internal/helpers/chat_command_edges_test.go | 9 +- internal/helpers/chat_media_upload.go | 1 + internal/helpers/chat_personal_emotion.go | 4 +- internal/helpers/conference.go | 5 +- internal/helpers/contact.go | 34 +-- .../helpers/contact_coverage_more_test.go | 9 +- internal/helpers/devapp_connect.go | 1 + internal/helpers/devdoc.go | 6 +- internal/helpers/ding.go | 6 +- internal/helpers/doc.go | 31 +-- internal/helpers/doc_style.go | 12 +- internal/helpers/doc_whiteboard.go | 4 +- internal/helpers/drive.go | 22 +- internal/helpers/file_comment.go | 4 +- .../helpers/group_command_declaration_test.go | 101 ++++++++ internal/helpers/helpers.go | 22 +- internal/helpers/hrbrain.go | 10 +- internal/helpers/interfaces.go | 8 +- internal/helpers/live.go | 6 +- internal/helpers/mail.go | 38 +-- internal/helpers/markdown.go | 4 +- internal/helpers/minutes.go | 28 +-- internal/helpers/oa.go | 10 +- internal/helpers/recruit.go | 8 +- internal/helpers/report.go | 11 +- internal/helpers/sheet.go | 33 ++- internal/helpers/sheet_comment.go | 4 +- internal/helpers/sheet_pivot_table.go | 4 +- internal/helpers/sheet_template.go | 4 +- internal/helpers/sheet_version.go | 4 +- .../helpers/small_remaining_coverage_test.go | 7 +- internal/helpers/todo.go | 10 +- internal/helpers/whiteboard.go | 4 +- internal/helpers/wiki.go | 22 +- internal/pat/pat.go | 4 +- internal/pipeline/command_resolution.go | 121 +++++---- internal/pipeline/command_resolution_test.go | 66 +++-- internal/shortcut/register.go | 2 + internal/shortcut/shortcut_test.go | 6 + pkg/cmdutil/command_suggestions.go | 142 +++++++++++ pkg/cmdutil/command_suggestions_test.go | 68 +++++ pkg/cmdutil/flags.go | 16 +- test/smoke/cli_smoke_test.go | 238 ++++++++++++++++++ 55 files changed, 1015 insertions(+), 387 deletions(-) create mode 100644 .changes/command-typo-guidance.md create mode 100644 internal/helpers/group_command_declaration_test.go create mode 100644 pkg/cmdutil/command_suggestions.go create mode 100644 pkg/cmdutil/command_suggestions_test.go diff --git a/.changes/command-typo-guidance.md b/.changes/command-typo-guidance.md new file mode 100644 index 00000000..76a13aa9 --- /dev/null +++ b/.changes/command-typo-guidance.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Command typo guidance** — returns a validation error with up to three nearest command suggestions and the parent `--help` entry instead of printing the full command list. diff --git a/internal/app/mcp_url_command.go b/internal/app/mcp_url_command.go index 70a02102..c6bf7d45 100644 --- a/internal/app/mcp_url_command.go +++ b/internal/app/mcp_url_command.go @@ -24,6 +24,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -43,6 +44,7 @@ func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command { return cmd.Help() }, } + cmdutil.MarkGroup(group) group.AddCommand(newMCPURLGetCommand(caller)) return group } diff --git a/internal/app/root.go b/internal/app/root.go index c35a2979..544d4b16 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -935,25 +935,30 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags)) mcpCmd.AddCommand(newMCPURLGroup(patCaller)) + utilityGroup := func(command *cobra.Command) *cobra.Command { + cmdutil.MarkGroup(command) + return command + } + utilityCommands := []*cobra.Command{ - newAuthCommand(patCaller), - newProfileCommand(), + utilityGroup(newAuthCommand(patCaller)), + utilityGroup(newProfileCommand()), newAPICommand(flags), - newSkillCommand(), - newCacheCommand(), + utilityGroup(newSkillCommand()), + utilityGroup(newCacheCommand()), newCatalogCommand(), - newConfigCommand(), + utilityGroup(newConfigCommand()), newDoctorCommand(), - newRecoveryCommand(), - newEventCommand(flags), - newAuditCommand(), + utilityGroup(newRecoveryCommand()), + utilityGroup(newEventCommand(flags)), + utilityGroup(newAuditCommand()), newCompletionCommand(root), newUpgradeCommand(), newVersionCommand(), newPluginCommand(), - usage.NewShortcutCommand(), + utilityGroup(usage.NewShortcutCommand()), schemaCmd, - mcpCmd, + utilityGroup(mcpCmd), } root.AddCommand(utilityCommands...) diff --git a/internal/app/root_help_test.go b/internal/app/root_help_test.go index f688c984..00900197 100644 --- a/internal/app/root_help_test.go +++ b/internal/app/root_help_test.go @@ -145,8 +145,10 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) { command.SilenceUsage = true command.SetArgs(tc.args) err := command.Execute() - if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) { - t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint) + var structured *apperrors.Error + if !stderrors.As(err, &structured) || structured.Category != apperrors.CategoryValidation || + structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) { + t.Fatalf("dws %s error = %#v, want migration hint %q", strings.Join(tc.args, " "), structured, tc.hint) } } diff --git a/internal/helpers/agoal.go b/internal/helpers/agoal.go index 6337d4aa..d01e2630 100644 --- a/internal/helpers/agoal.go +++ b/internal/helpers/agoal.go @@ -15,7 +15,7 @@ var agoalLoadLocation = time.LoadLocation // ────────────────────────────────────────────────────────── func newAgoalCommand() *cobra.Command { - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "agoal", Short: "Agoal 管理", Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。 @@ -39,11 +39,11 @@ func newAgoalCommand() *cobra.Command { dws agoal obj-template list 获取目标模板列表 dws agoal obj-template create-or-update 新增或更新目标模板`, RunE: groupRunE, - } + }) // ── strategy: 战略解码管理 ────────────────────────────────── - strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE} + strategyCmd := newGroupCommand(&cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE}) strategyListCmd := &cobra.Command{ Use: "list", @@ -139,7 +139,7 @@ scopeType 支持: // ── contract: 经营合约管理 ────────────────────────────────── - contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE} + contractCmd := newGroupCommand(&cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE}) contractListCmd := &cobra.Command{ Use: "list", @@ -258,7 +258,7 @@ scopeType 支持: // ── scorecard: 计分卡管理 ─────────────────────────────────── - scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE} + scorecardCmd := newGroupCommand(&cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE}) scorecardDetailCmd := &cobra.Command{ Use: "detail", @@ -394,7 +394,7 @@ scopeType 支持: // ── user: 用户目标管理 ────────────────────────────────────── - userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE} + userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}) userRulesCmd := &cobra.Command{ Use: "rules", @@ -445,7 +445,7 @@ scopeType 支持: // ── report: 周月报管理 ────────────────────────────────────── - reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE} + reportCmd := newGroupCommand(&cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE}) reportListStatisticsCmd := &cobra.Command{ Use: "list-statistics", @@ -520,7 +520,7 @@ scopeType 支持: // ── template: 目标模板管理 ────────────────────────────────── - objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE} + objTemplateCmd := newGroupCommand(&cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE}) objTemplateListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/aisearch.go b/internal/helpers/aisearch.go index 92cbe0a6..b83327f3 100644 --- a/internal/helpers/aisearch.go +++ b/internal/helpers/aisearch.go @@ -210,6 +210,7 @@ func newAisearchCommand() *cobra.Command { return groupRunE(cmd, args) }, } + markGroup(root) // root 和 person 各自定义同一组本地 flag,这样: // - dws aisearch --query xxx ← root 自己能解析 diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index 22370853..dfa3e556 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -1562,7 +1562,7 @@ func newAitableCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "aitable", Short: "AI 表格操作", Long: `管理钉钉 AI 表格:Base 管理、数据表、字段、记录、视图、表单、仪表盘、图表、导入导出。 @@ -1586,11 +1586,11 @@ func newAitableCommand() *cobra.Command { dws aitable section [create|rename|delete|reorder|list-empty|list-nodes|move-node] 文件夹与节点管理`, RunE: groupRunE, SuggestionsMinimumDistance: 2, // Enable "Did you mean ...?" for typos - } + }) // ── base: Base 管理 ───────────────────────────────────────── - baseCmd := &cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE} + baseCmd := newGroupCommand(&cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE}) baseGetPrimaryDocIdCmd := &cobra.Command{ Use: "get-primary-doc-id", @@ -1952,7 +1952,7 @@ MCP 层不会会自动解析 URL,必须直接传入 dentryUuid 以避免报错 // ── table: 数据表管理 ─────────────────────────────────────── - tableCmd := &cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE} + tableCmd := newGroupCommand(&cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE}) tableGetCmd := &cobra.Command{ Use: "get", @@ -2223,7 +2223,7 @@ config 结构参考: // ── field: 字段管理 ───────────────────────────────────────── - fieldCmd := &cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE} + fieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE}) fieldGetCmd := &cobra.Command{ Use: "get", @@ -2582,7 +2582,7 @@ newFieldName、config、aiConfig 至少传入一项。 // ── record: 记录管理 ──────────────────────────────────────── - recordCmd := &cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE} + recordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE}) recordQueryCmd := &cobra.Command{ Use: "query", @@ -3564,7 +3564,7 @@ fieldId 必须是 primaryDoc 类型的字段。`, // ── template: 模板搜索 ────────────────────────────────────── - templateCmd := &cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE} + templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE}) templateSearchCmd := &cobra.Command{ Use: "search", @@ -3613,7 +3613,7 @@ fieldId 必须是 primaryDoc 类型的字段。`, // ── attachment: 附件管理 ────────────────────────────────────── - attachmentCmd := &cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE} + attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE}) attachmentUploadCmd := &cobra.Command{ Use: "upload", @@ -3690,7 +3690,7 @@ fieldId 必须是 primaryDoc 类型的字段。`, // ── view: 视图管理 ─────────────────────────────────────────── - viewCmd := &cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE} + viewCmd := newGroupCommand(&cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE}) viewGetCmd := &cobra.Command{ Use: "get", @@ -3721,6 +3721,7 @@ fieldId 必须是 primaryDoc 类型的字段。`, return callAitableTool("get_views", toolArgs) }, } + markGroup(viewGetCmd) // ─── view get 子命令:按属性投影 view 响应 ────────────── // card/timebar/aggregate 需要 viewType 校验;filter/sort/group/visible-fields/field-widths 不需要。 @@ -4114,6 +4115,7 @@ fieldWidths 仅支持 Grid 视图。 return callAitableTool("update_view", toolArgs) }, } + markGroup(viewUpdateCmd) // ─── view update 子命令:按属性局部更新 ──────────────────── @@ -5058,9 +5060,9 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`, // ── form: 表单管理 ────────────────────────────────────────── - formCmd := &cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE} - formFieldCmd := &cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE} - formShareCmd := &cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE} + formCmd := newGroupCommand(&cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE}) + formFieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE}) + formShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE}) formListCmd := &cobra.Command{ Use: "list", @@ -5312,7 +5314,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`, // ── form questions: 表单题目(form 视角的字段管理,等价于 field create / field delete) ── - formQuestionsCmd := &cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE} + formQuestionsCmd := newGroupCommand(&cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE}) formQuestionsCreateCmd := &cobra.Command{ Use: "create", @@ -5607,11 +5609,11 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`, // ── workflow: 自动化工作流管理 ──────────────────────────────── - workflowCmd := &cobra.Command{ + workflowCmd := newGroupCommand(&cobra.Command{ Use: "workflow", Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)", RunE: groupRunE, - } + }) workflowCreateCmd := &cobra.Command{ Use: "create", @@ -6066,7 +6068,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正 // ── dashboard: 仪表盘管理 ──────────────────────────────────── - dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE} + dashboardCmd := newGroupCommand(&cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}) dashboardConfigExampleCmd := &cobra.Command{ Use: "config-example", @@ -6361,7 +6363,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`, // ── dashboard share: 仪表盘分享管理 ──────────────────────────── - dashboardShareCmd := &cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE} + dashboardShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE}) dashboardShareGetCmd := &cobra.Command{ Use: "get", @@ -6463,7 +6465,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`, // ── chart: 图表管理 ────────────────────────────────────────── - chartCmd := &cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE} + chartCmd := newGroupCommand(&cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE}) chartWidgetsExampleCmd := &cobra.Command{ Use: "widgets-example", @@ -6702,7 +6704,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`, // ── chart share: 图表分享管理 ──────────────────────────────── - chartShareCmd := &cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE} + chartShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE}) chartShareGetCmd := &cobra.Command{ Use: "get", @@ -6806,7 +6808,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`, // ── export / import: 数据导入导出 ──────────────────────────── - exportCmd := &cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE} + exportCmd := newGroupCommand(&cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE}) exportDataCmd := &cobra.Command{ Use: "data", @@ -6911,11 +6913,11 @@ export-format 可选值:excel、attachment、excel_and_attachment、excel_with }, }) - importCmd := &cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE} + importCmd := newGroupCommand(&cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE}) // ── advperm: 高级权限 / 自定义角色 ──────────────────────────── - advpermCmd := &cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE} + advpermCmd := newGroupCommand(&cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE}) advpermEnableCmd := &cobra.Command{ Use: "enable", @@ -7392,7 +7394,7 @@ role-get 自行 merge)。 // ── section: 文件夹与节点管理(导航树组织) ────────────────────────────── - sectionCmd := &cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE} + sectionCmd := newGroupCommand(&cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE}) sectionCreateCmd := &cobra.Command{ Use: "create", @@ -8424,7 +8426,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。 // ── datasource: 数据源同步管理 ────────────────────────────── - datasourceCmd := &cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE} + datasourceCmd := newGroupCommand(&cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}) datasourceGetConfigCmd := &cobra.Command{ Use: "get-config", diff --git a/internal/helpers/attendance.go b/internal/helpers/attendance.go index b8fbcb41..cab5652d 100644 --- a/internal/helpers/attendance.go +++ b/internal/helpers/attendance.go @@ -528,7 +528,7 @@ func newAttendanceCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "attendance", Short: "考勤打卡 / 排班 / 统计", Long: `管理钉钉考勤:查询个人考勤详情、班次查询、排班管理、获取考勤统计摘要、查询考勤组与规则。 @@ -546,11 +546,11 @@ func newAttendanceCommand() *cobra.Command { globalsetting 全局规则设置项(get 查询,save 更新,仅管理员可调用,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知) vacation 查询当前用户假期规则列表、查询员工假期余额、查询假期余额变更记录`, RunE: groupRunE, - } + }) // ── record ─────────────────────────────────────────────── - attendanceRecordCmd := &cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE} + attendanceRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE}) attendanceRecordGetCmd := &cobra.Command{ Use: "get", @@ -612,7 +612,7 @@ func newAttendanceCommand() *cobra.Command { // ── check ──────────────────────────────────────────────── - attendanceCheckCmd := &cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE} + attendanceCheckCmd := newGroupCommand(&cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE}) // MCP tool: query_check_result attendanceCheckResultCmd := &cobra.Command{ @@ -783,7 +783,7 @@ func newAttendanceCommand() *cobra.Command { // ── approve ──────────────────────────────────────────────── - attendanceApproveCmd := &cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE} + attendanceApproveCmd := newGroupCommand(&cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE}) // 审批类型关键词到 bizType 数字映射 // 注意:服务端 bizType=2 同时覆盖 出差 与 外出(合并为同一类), @@ -992,13 +992,13 @@ func newAttendanceCommand() *cobra.Command { // ── shift ──────────────────────────────────────────────── - attendanceShiftCmd := &cobra.Command{ + attendanceShiftCmd := newGroupCommand(&cobra.Command{ Use: "shift", Short: "班次查询", Long: `查询员工班次信息(班次 = 员工当天的打卡安排)。 返回每条记录含:用户 ID、工作日期、打卡类型(OnDuty/OffDuty)、计划打卡时间、是否休息日。`, RunE: groupRunE, - } + }) // MCP tool: batch_get_employee_shifts attendanceShiftListCmd := &cobra.Command{ @@ -1068,7 +1068,7 @@ func newAttendanceCommand() *cobra.Command { // ── class ──────────────────────────────────────────────── - attendanceClassCmd := &cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE} + attendanceClassCmd := newGroupCommand(&cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE}) // MCP tool: get_class_list attendanceClassSearchCmd := &cobra.Command{ @@ -1415,7 +1415,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换 // ── adjustment-rule ──────────────────────────────────── - attendanceAdjustmentCmd := &cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE} + attendanceAdjustmentCmd := newGroupCommand(&cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE}) // MCP tool: get_adjustment_rule_detail attendanceAdjustmentGetCmd := &cobra.Command{ @@ -1540,7 +1540,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换 // ── overtime-rule ────────────────────────────────────── - attendanceOvertimeCmd := &cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE} + attendanceOvertimeCmd := newGroupCommand(&cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE}) // MCP tool: get_overtime_rule_detail attendanceOvertimeGetCmd := &cobra.Command{ @@ -1665,7 +1665,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换 // ── group ────────────────────────────────────────────── - attendanceGroupCmd := &cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE} + attendanceGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE}) // MCP tool: get_simple_groups attendanceGroupSearchCmd := &cobra.Command{ @@ -2574,7 +2574,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, }) // ── selfsetting ───────────────────────────────────────────── - attendanceSelfSettingCmd := &cobra.Command{ + attendanceSelfSettingCmd := newGroupCommand(&cobra.Command{ Use: "selfsetting", Short: "个人规则设置", Long: `个人规则设置相关命令。 @@ -2583,7 +2583,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, get 查询个人规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。 save 更新保存个人规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`, RunE: groupRunE, - } + }) // MCP tool: query_self_setting attendanceSelfSettingGetCmd := &cobra.Command{ @@ -2827,7 +2827,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, }) // ── globalsetting ──────────────────────────────────────── - attendanceGlobalSettingCmd := &cobra.Command{ + attendanceGlobalSettingCmd := newGroupCommand(&cobra.Command{ Use: "globalsetting", Short: "全局规则设置(仅管理员)", Long: `全局规则设置相关命令,仅管理员可以调用。 @@ -2836,7 +2836,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, get 查询全局规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。 save 更新保存全局规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`, RunE: groupRunE, - } + }) // MCP tool: query_global_setting attendanceGlobalSettingGetCmd := &cobra.Command{ @@ -2999,7 +2999,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, // ── report ────────────────────────────────────────────── - attendanceReportCmd := &cobra.Command{ + attendanceReportCmd := newGroupCommand(&cobra.Command{ Use: "report", Short: "查询考勤报表和结果", Long: `考勤 MCP 报表接口,仅对管理员开放 @@ -3009,7 +3009,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, query-data 根据字段查询考勤数据 query-leave 查询用户假期数据`, RunE: groupRunE, - } + }) // MCP tool: get_report_columns reportColumnsCmd := &cobra.Command{ @@ -3218,7 +3218,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, // ── 假期 vacation ─────────────────────────────────────────────── - attendanceVacationCmd := &cobra.Command{ + attendanceVacationCmd := newGroupCommand(&cobra.Command{ Use: "vacation", Short: "假期管理", Long: `管理钉钉假期:查询假期规则列表、查询员工假期余额、查询假期余额变更记录。 @@ -3230,7 +3230,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, save-balance 更新员工假期余额 records 查询指定员工假期余额变更记录`, RunE: groupRunE, - } + }) // ── 假期规则 types ───────────────────────────────────────── @@ -3771,7 +3771,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, // ── schedule ────────────────────────────────────────────── - attendanceScheduleCmd := &cobra.Command{ + attendanceScheduleCmd := newGroupCommand(&cobra.Command{ Use: "schedule", Short: "排班管理", Long: `排班制考勤组的排班记录导入与查询(排班 = 为员工安排具体工作日期和班次)。 @@ -3779,7 +3779,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, import 导入排班记录到排班制考勤组 get 获取指定用户的排班记录`, RunE: groupRunE, - } + }) // schedule import (generateTurnSchedule) scheduleImportCmd := &cobra.Command{ @@ -4219,14 +4219,14 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`, // ── checkin ────────────────────────────────────────────── - checkinCmd := &cobra.Command{ + checkinCmd := newGroupCommand(&cobra.Command{ Use: "checkin", Short: "签到管理", Long: `签到记录的查询。 子命令: records 查询指定员工的签到记录`, RunE: groupRunE, - } + }) // MCP tool: queryUserRecordByStaffIds checkinRecordsCmd := &cobra.Command{ diff --git a/internal/helpers/calendar.go b/internal/helpers/calendar.go index 5c71ea67..695b644a 100644 --- a/internal/helpers/calendar.go +++ b/internal/helpers/calendar.go @@ -11,6 +11,8 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -18,10 +20,9 @@ import ( // dws calendar — 日历产品命令组 // ────────────────────────────────────────────────────────── -// calendarInfoHintSubCmd builds a hidden disambiguation subcommand that prints -// a warning-level "Did you mean" hint to stderr (instead of returning an Error) -// and exits 0. Scoped to calendar.go on purpose so the shared cmdutil.HintSubCmd -// used by other products keeps returning errors as before. +// calendarInfoHintSubCmd builds a hidden disambiguation subcommand that returns +// the shared typed validation error while preserving Calendar's reviewed +// replacement guidance. // // The `suggestion` argument should be the bare corrected command (no leading // "use:" / "hint:" prefix); the helper wraps it with the standard "Did you @@ -29,14 +30,6 @@ import ( func calendarInfoHintSubCmd(use, suggestion string) *cobra.Command { c := hintSubCmd(use, suggestion) c.DisableFlagParsing = true - c.RunE = func(cmd *cobra.Command, args []string) error { - fmt.Fprintf(os.Stderr, "warning: command %q does not exist\n hint: %s\t %s\n more: %s \n", - cmd.Parent().CommandPath()+" "+use, - suggestion, - "[MUST] use --help to see command detail", - "'dws calendar --help' to see all available commands") - return nil - } return c } @@ -56,6 +49,7 @@ func calendarInfoHintSubCmd(use, suggestion string) *cobra.Command { // child's RunE as usual; this fallback only fires when resolution stops at // `group` with leftover args. func installUnknownVerbFallback(group *cobra.Command) { + cmdutil.MarkGroup(group) group.FParseErrWhitelist.UnknownFlags = true group.Args = cobra.ArbitraryArgs @@ -92,8 +86,7 @@ func installUnknownVerbFallback(group *cobra.Command) { // Unknown flags whitelisted by pflag may leak into args. Pick the first // non-flag token as the offending verb. if bad := findUnknownVerb(cmd, args); bad != "" { - printUnknownSubcmdError(cmd, bad) - return nil + return groupRunE(cmd, []string{bad}) } // No unknown verb found. Since FParseErrWhitelist.UnknownFlags silently // swallows bad flags, scan the original os.Args for flags unregistered @@ -140,22 +133,10 @@ func findUnknownVerb(cmd *cobra.Command, args []string) string { return "" } -// printUnknownSubcmdError prints the standard "unknown subcommand" error to -// stderr with available commands and a did-you-mean hint. +// printUnknownSubcmdError renders the shared bounded diagnostic for Cobra's +// HelpFunc path, which cannot return the structured error to its caller. func printUnknownSubcmdError(cmd *cobra.Command, bad string) { - var available []string - for _, c := range cmd.Commands() { - if !c.Hidden && c.Name() != "help" { - available = append(available, c.Name()) - } - } - fmt.Fprintf(os.Stderr, "Error: unknown subcommand %q for %q\n", bad, cmd.CommandPath()) - fmt.Fprintf(os.Stderr, " available: %s\n", strings.Join(available, ", ")) - if s := cmd.SuggestionsFor(bad); len(s) > 0 { - fmt.Fprintf(os.Stderr, " hint: did you mean %q\n", cmd.CommandPath()+" "+s[0]) - } else { - fmt.Fprintf(os.Stderr, " hint: %s --help\n", cmd.CommandPath()) - } + _ = apperrors.PrintHuman(cmd.ErrOrStderr(), groupRunE(cmd, []string{bad})) } // stripCommandPrefix strips the first `depth` non-flag tokens from args. @@ -224,7 +205,7 @@ func newCalendarCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "calendar", Short: "日历日程 / 会议室 / 闲忙", Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。 @@ -238,11 +219,11 @@ func newCalendarCommand() *cobra.Command { dws calendar book [list|get|search|update] 日历本管理 dws calendar acl [list|add|delete] 日历访问权限管理`, RunE: groupRunE, - } + }) // ── event: 日程 ───────────────────────────────────────────── - eventCmd := &cobra.Command{Use: "event", Short: "日程管理", RunE: groupRunE} + eventCmd := newGroupCommand(&cobra.Command{Use: "event", Short: "日程管理", RunE: groupRunE}) eventListCmd := &cobra.Command{ Use: "list", @@ -816,13 +797,13 @@ func newCalendarCommand() *cobra.Command { // ── attendee: 参会人 (曾用名: participant) ───────────────── - participantCmd := &cobra.Command{ + participantCmd := newGroupCommand(&cobra.Command{ Use: "attendee", Aliases: []string{"participant"}, Short: "日程参会人管理", Long: "管理日程的参会人。alias:`participant`,仍作为别名保留,历史调用无需改动。", RunE: groupRunE, - } + }) participantListCmd := &cobra.Command{ Use: "list", @@ -1008,7 +989,7 @@ func newCalendarCommand() *cobra.Command { // ── room: 会议室 ──────────────────────────────────────────── - roomCmd := &cobra.Command{Use: "room", Short: "会议室管理", RunE: groupRunE} + roomCmd := newGroupCommand(&cobra.Command{Use: "room", Short: "会议室管理", RunE: groupRunE}) roomSearchCmd := &cobra.Command{ Use: "search", @@ -1352,7 +1333,7 @@ func newCalendarCommand() *cobra.Command { // ── busy: 闲忙 ────────────────────────────────────────────── - busyCmd := &cobra.Command{Use: "busy", Short: "闲忙查询", RunE: groupRunE} + busyCmd := newGroupCommand(&cobra.Command{Use: "busy", Short: "闲忙查询", RunE: groupRunE}) busySearchCmd := &cobra.Command{ Use: "search", @@ -1444,7 +1425,7 @@ func newCalendarCommand() *cobra.Command { // ── attachment: 附件 ──────────────────────────────────────── - attachmentCmd := &cobra.Command{Use: "attachment", Short: "日程附件管理", RunE: groupRunE} + attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "日程附件管理", RunE: groupRunE}) attachmentAddCmd := &cobra.Command{ Use: "add", @@ -1529,7 +1510,7 @@ func newCalendarCommand() *cobra.Command { // ── acl: 日历访问权限 ───────────────────────────────────────── - aclCmd := &cobra.Command{Use: "acl", Short: "管理我的日历访问权限(共享给他人)", RunE: groupRunE} + aclCmd := newGroupCommand(&cobra.Command{Use: "acl", Short: "管理我的日历访问权限(共享给他人)", RunE: groupRunE}) aclListCmd := &cobra.Command{ Use: "list", @@ -1619,7 +1600,7 @@ func newCalendarCommand() *cobra.Command { // ── book: 日历本 ──────────────────────────────────────────── - bookCmd := &cobra.Command{Use: "book", Short: "日历本管理(我能看哪些日历)", RunE: groupRunE} + bookCmd := newGroupCommand(&cobra.Command{Use: "book", Short: "日历本管理(我能看哪些日历)", RunE: groupRunE}) bookListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/calendar_coverage_more_test.go b/internal/helpers/calendar_coverage_more_test.go index 5c552e09..1598ec4b 100644 --- a/internal/helpers/calendar_coverage_more_test.go +++ b/internal/helpers/calendar_coverage_more_test.go @@ -191,7 +191,9 @@ func TestCrossPlatformCoverageCalendarUnknownFallbackCoverage(t *testing.T) { hint := calendarInfoHintSubCmd("query", "use search") group.AddCommand(hint) - _ = hint.RunE(hint, nil) + if err := hint.RunE(hint, nil); err == nil { + t.Fatal("calendar compatibility hint succeeded") + } oldArgs := os.Args t.Cleanup(func() { os.Args = oldArgs }) diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 94720e8d..da94bccf 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -2403,13 +2403,13 @@ func newChatCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "chat", Aliases: []string{"im"}, Short: "群聊 / 消息 / 机器人", Long: `管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取/发送/收藏会话消息、机器人消息与 Webhook。`, RunE: groupRunE, - } + }) chatChmodCmd := &cobra.Command{ Use: "chmod ", @@ -2499,12 +2499,12 @@ func newChatCommand() *cobra.Command { }, }) - chatDataAuthCmd := &cobra.Command{ + chatDataAuthCmd := newGroupCommand(&cobra.Command{ Use: "data-auth", Short: "授予 chat 数据读取权限", Long: `授予 chat 数据读取权限。该命令用于跨组织消息拉取等数据访问场景,不用于发送、撤回、群管理等命令操作。`, RunE: groupRunE, - } + }) chatDataAuthCrossOrgCmd := &cobra.Command{ Use: "cross-org", Short: "授予跨组织 chat 数据访问权限", @@ -2576,7 +2576,7 @@ func newChatCommand() *cobra.Command { // ── group 子命令 ────────────────────────────────────────── - chatGroupCmd := &cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE} + chatGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "群组管理", RunE: groupRunE}) chatGroupCreateCmd := &cobra.Command{ Use: "create", @@ -2766,6 +2766,7 @@ func newChatCommand() *cobra.Command { return callMCPTool("get_group_members", toolArgs) }, } + markGroup(chatGroupMembersCmd) chatGroupMembersAddBotCmd := &cobra.Command{ Use: "add-bot", @@ -2974,12 +2975,12 @@ func newChatCommand() *cobra.Command { // ── message 子命令 ──────────────────────────────────────── - chatMessageCmd := &cobra.Command{ + chatMessageCmd := newGroupCommand(&cobra.Command{ Use: "message", Short: "会话消息管理", Long: `管理会话消息,包括拉取、发送、搜索、转发、钉住、收藏和撤回消息。`, RunE: groupRunE, - } + }) chatMessageListCmd := &cobra.Command{ Use: "list", @@ -4674,7 +4675,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id // ── bot 子命令 ──────────────────────────────────────────── - chatBotCmd := &cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE} + chatBotCmd := newGroupCommand(&cobra.Command{Use: "bot", Short: "机器人管理", RunE: groupRunE}) chatBotSearchCmd := &cobra.Command{ Use: "search", @@ -5210,12 +5211,12 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id // ── file 子命令(会话文件上传,不暴露 spaceId)─────────────── - chatFileCmd := &cobra.Command{ + chatFileCmd := newGroupCommand(&cobra.Command{ Use: "file", Short: "会话文件上传(已下线)", Hidden: true, RunE: groupRunE, - } + }) chatFileUploadCmd := &cobra.Command{ Use: "upload", @@ -5250,7 +5251,7 @@ chat message edit 或 chat message recall 的 --message-id 和 --conversation-id // ── category 子命令(会话分组,走 IM MCP)─────────────────── - chatCategoryCmd := &cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE} + chatCategoryCmd := newGroupCommand(&cobra.Command{Use: "category", Short: "会话分组管理", RunE: groupRunE}) chatCategoryListCmd := &cobra.Command{ Use: "list", @@ -7580,7 +7581,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 // ── group-role 子命令(群身份管理)──────────────────────── - chatGroupRoleCmd := &cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE} + chatGroupRoleCmd := newGroupCommand(&cobra.Command{Use: "group-role", Short: "群身份管理", RunE: groupRunE}) chatGroupRoleListCmd := &cobra.Command{ Use: "list", @@ -9864,7 +9865,7 @@ status 可选值: }) // ── group notice: 群公告管理 ──────────────────────────────── - chatGroupNoticeCmd := &cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE} + chatGroupNoticeCmd := newGroupCommand(&cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE}) chatGroupNoticeCreateCmd := &cobra.Command{ Use: "create", @@ -10385,7 +10386,7 @@ status 可选值: "fi_FI": true, "cs_CZ": true, "ar_SA": true, "tl_PH": true, "he_IL": true, "nl_NL": true, "lo_LA": true, "it_IT": true, } - chatTextCmd := &cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE} + chatTextCmd := newGroupCommand(&cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE}) chatTextTranslateCmd := &cobra.Command{ Use: "translate", Short: "翻译文本内容", @@ -10450,11 +10451,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`, chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd, chatGroupUpgradeToExternalCmd) // ── chat group user-settings ── - chatGroupUserSettingsCmd := &cobra.Command{ + chatGroupUserSettingsCmd := newGroupCommand(&cobra.Command{ Use: "user-settings", Short: "批量查询或更新当前用户的群会话设置", RunE: groupRunE, - } + }) chatGroupUserSettingsQueryCmd := &cobra.Command{ Use: "query", Short: "批量查询当前用户的群会话设置", diff --git a/internal/helpers/chat/toolbar.go b/internal/helpers/chat/toolbar.go index 379568f3..3e9367f4 100644 --- a/internal/helpers/chat/toolbar.go +++ b/internal/helpers/chat/toolbar.go @@ -13,7 +13,10 @@ package chat -import "github.com/spf13/cobra" +import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) func newChatToolbarCommand() *cobra.Command { toolbarCmd := &cobra.Command{ @@ -22,6 +25,7 @@ func newChatToolbarCommand() *cobra.Command { Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。", RunE: groupRunE, } + cmdutil.MarkGroup(toolbarCmd) toolbarCmd.DisableAutoGenTag = true toolbarCmd.AddCommand( diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index b2f70a1b..0c72cbdd 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -121,8 +122,12 @@ func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *tes } root.SetArgs(tc.args) err = root.ExecuteContext(context.Background()) - if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) { - t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint) + var structured *apperrors.Error + if !errors.As(err, &structured) { + t.Fatalf("chat %s with legacy flags error = %T %v, want structured validation", tc.path, err, err) + } + if structured.Category != apperrors.CategoryValidation || structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) { + t.Fatalf("chat %s with legacy flags error = %#v, want migration hint %q", tc.path, structured, tc.hint) } } } diff --git a/internal/helpers/chat_media_upload.go b/internal/helpers/chat_media_upload.go index 0c0f5c03..cc132e41 100644 --- a/internal/helpers/chat_media_upload.go +++ b/internal/helpers/chat_media_upload.go @@ -33,6 +33,7 @@ func newChatMediaGroup() *cobra.Command { }, } media.AddCommand(newChatMediaUploadCommand()) + markGroup(media) return media } diff --git a/internal/helpers/chat_personal_emotion.go b/internal/helpers/chat_personal_emotion.go index eb46fe67..26138cbd 100644 --- a/internal/helpers/chat_personal_emotion.go +++ b/internal/helpers/chat_personal_emotion.go @@ -13,12 +13,12 @@ import ( const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command." func newChatEmotionCommand() *cobra.Command { - cmd := &cobra.Command{ + cmd := newGroupCommand(&cobra.Command{ Use: "emotion", Short: "个人收藏表情", Long: "查询、发送和新增当前用户的个人收藏表情。", RunE: groupRunE, - } + }) cmd.AddCommand( newChatEmotionListCommand(), newChatEmotionSendCommand(), diff --git a/internal/helpers/conference.go b/internal/helpers/conference.go index 2b34ba44..50225b99 100644 --- a/internal/helpers/conference.go +++ b/internal/helpers/conference.go @@ -39,8 +39,9 @@ func newConferenceCommand() *cobra.Command { 直接发起会议、邀请入会、会中控制请在钉钉客户端操作;如需预约日程,请改用 dws calendar event create。`, RunE: runUnavailable, } + markGroup(root) - meetingCmd := &cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE} + meetingCmd := newGroupCommand(&cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE}) meetingCreateCmd := &cobra.Command{ Use: "reserve", @@ -59,7 +60,7 @@ func newConferenceCommand() *cobra.Command { root.AddCommand(meetingCmd) // member 子命令组 — 成员管理 - memberCmd := &cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE} + memberCmd := newGroupCommand(&cobra.Command{Use: "member", Short: "成员管理(已下线)", RunE: groupRunE}) memberInviteCmd := &cobra.Command{ Use: "invite", diff --git a/internal/helpers/contact.go b/internal/helpers/contact.go index e97ce6b7..54af5c95 100644 --- a/internal/helpers/contact.go +++ b/internal/helpers/contact.go @@ -422,7 +422,7 @@ func newContactCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "contact", Short: "通讯录 / 用户 / 部门 / 角色 / 人员关系", Long: `查询钉钉通讯录:用户搜索、手机号查找、部门搜索、子部门 / 成员列表、人员关系;用户花名册档案信息(学历、家庭、银行卡、合同等)与离职员工信息。 @@ -441,9 +441,9 @@ func newContactCommand() *cobra.Command { - contact user profile fields/get: 员工花名册档案查询(学历、家庭、银行卡等) - contact user dismission search: 离职员工列表查询`, RunE: groupRunE, - } + }) - userCmd := &cobra.Command{ + userCmd := newGroupCommand(&cobra.Command{ Use: "user", Short: "人员管理", Long: `人员管理:通讯录用户查询、修改员工信息、邀请员工加入企业、用户档案(花名册)查询、离职员工查询。 @@ -457,7 +457,7 @@ func newContactCommand() *cobra.Command { - 查询用户的学历、家庭、银行卡、合同等档案 → contact user profile get - 查询离职员工列表 → contact user dismission search`, RunE: groupRunE, - } + }) contactUserGetSelfCmd := &cobra.Command{ Use: "get-self", @@ -499,10 +499,10 @@ func newContactCommand() *cobra.Command { }, }) - relationCmd := &cobra.Command{Use: "relation", + relationCmd := newGroupCommand(&cobra.Command{Use: "relation", Short: "人员关系查询", Long: `查询钉钉人员关系:特别关注人。`, - RunE: groupRunE} + RunE: groupRunE}) contactRelationListMyFollowingsCmd := &cobra.Command{ Use: "list-my-followings", @@ -704,7 +704,7 @@ func newContactCommand() *cobra.Command { // ── label 角色 ────────────────────────────────────────────────── - contactLabelCmd := &cobra.Command{ + contactLabelCmd := newGroupCommand(&cobra.Command{ Use: "label", Aliases: []string{"role"}, Short: "角色查询", @@ -720,7 +720,7 @@ func newContactCommand() *cobra.Command { 2. 从返回结果中匹配目标角色名称及 labelId 3. contact label list-members --id → 获取该角色下的成员`, RunE: groupRunE, - } + }) runContactLabelList := func(cmd *cobra.Command, args []string) error { if len(args) > 0 { @@ -796,7 +796,7 @@ func newContactCommand() *cobra.Command { contactLabelCmd.AddCommand(contactLabelListAllCmd, contactLabelGetCmd, contactLabelListMembersCmd) - contactDeptCmd := &cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE} + contactDeptCmd := newGroupCommand(&cobra.Command{Use: "dept", Short: "部门查询", RunE: groupRunE}) contactDeptSearchCmd := &cobra.Command{ Use: "search", @@ -988,7 +988,7 @@ func newContactCommand() *cobra.Command { }) // ── user profile 用户档案(花名册) ──────────────────────────────────── - contactUserProfileCmd := &cobra.Command{ + contactUserProfileCmd := newGroupCommand(&cobra.Command{ Use: "profile", Short: "用户档案(花名册)", Long: `用户档案(花名册):查询花名册字段列表、查询员工花名册字段信息。 @@ -1000,7 +1000,7 @@ func newContactCommand() *cobra.Command { - contact user get: 组织管理信息(部门、主管、管理员权限) - contact user profile get: 个人档案信息(学历、家庭、银行卡等)`, RunE: groupRunE, - } + }) contactUserProfileFieldsCmd := &cobra.Command{ Use: "fields", @@ -1124,12 +1124,12 @@ contact user profile fields 获取可用字段列表。 contactUserProfileCmd.AddCommand(contactUserProfileFieldsCmd, contactUserProfileGetCmd) // ── user dismission 离职员工 ─────────────────────────────────────────── - contactUserDismissionCmd := &cobra.Command{ + contactUserDismissionCmd := newGroupCommand(&cobra.Command{ Use: "dismission", Short: "离职员工查询", Long: `离职员工查询:分页获取离职员工列表,支持按员工姓名、离职时间范围、部门进行过滤。`, RunE: groupRunE, - } + }) contactUserDismissionSearchCmd := &cobra.Command{ Use: "search", @@ -1568,7 +1568,7 @@ contact user profile fields 获取可用字段列表。 // ── org 企业管理 ────────────────────────────────────────────────── - contactOrgCmd := &cobra.Command{ + contactOrgCmd := newGroupCommand(&cobra.Command{ Use: "org", Short: "企业管理", Long: `企业管理:创建企业。 @@ -1578,7 +1578,7 @@ contact user profile fields 获取可用字段列表。 - 创建企业专属账号 → contact account create - 邀请员工加入企业 → contact user invite`, RunE: groupRunE, - } + }) contactOrgCreateCmd := &cobra.Command{ Use: "create", @@ -1639,12 +1639,12 @@ contact user profile fields 获取可用字段列表。 // ── account 企业账号管理 ────────────────────────────────────────── - contactAccountCmd := &cobra.Command{ + contactAccountCmd := newGroupCommand(&cobra.Command{ Use: "account", Short: "企业账号管理", Long: "企业账号管理:创建或更新企业专属账号。", RunE: groupRunE, - } + }) contactAccountCreateCmd := &cobra.Command{ Use: "create", diff --git a/internal/helpers/contact_coverage_more_test.go b/internal/helpers/contact_coverage_more_test.go index 3579151b..cb4c5b6c 100644 --- a/internal/helpers/contact_coverage_more_test.go +++ b/internal/helpers/contact_coverage_more_test.go @@ -1,9 +1,11 @@ package helpers import ( + "errors" "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -37,8 +39,11 @@ func TestCrossPlatformCoverageContactRemainingCompatibilityBranches(t *testing.T if err := hint.RunE(hint, []string{"--help"}); err != nil { t.Fatalf("hint help: %v", err) } - if err := hint.RunE(hint, []string{"unexpected"}); err == nil || !strings.Contains(err.Error(), "use: dws contact dept") { - t.Fatalf("hint guidance err=%v", err) + err := hint.RunE(hint, []string{"unexpected"}) + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation || + structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, "use: dws contact dept") { + t.Fatalf("hint guidance err=%#v", structured) } if err := executeFilterCoverage(t, newContactCommand(), "user", "get", "--unknown"); err == nil || !strings.Contains(err.Error(), "See '") { diff --git a/internal/helpers/devapp_connect.go b/internal/helpers/devapp_connect.go index c8d96c35..c4e44962 100644 --- a/internal/helpers/devapp_connect.go +++ b/internal/helpers/devapp_connect.go @@ -587,6 +587,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { newDevAppRobotConnectRestartCommand(), newDevAppRobotConnectListCommand(runner), ) + markGroup(cmd) cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)") cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD") // 用 robot-client-* 而非 client-id/client-secret:后者是全局 OAuth 客户端覆盖 diff --git a/internal/helpers/devdoc.go b/internal/helpers/devdoc.go index cc926c01..ab5ae5b0 100644 --- a/internal/helpers/devdoc.go +++ b/internal/helpers/devdoc.go @@ -24,14 +24,14 @@ func newDevdocCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "devdoc", Short: "开放平台文档搜索", Long: `搜索钉钉开放平台开发文档。默认以表格格式输出(标题、URL),使用 -f json 获取原始 JSON。`, RunE: groupRunE, - } + }) - articleCmd := &cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE} + articleCmd := newGroupCommand(&cobra.Command{Use: "article", Short: "文档文章", RunE: groupRunE}) articleCmd.AddCommand(newDevdocArticleSearchCommand()) root.AddCommand(articleCmd) root.AddCommand(hintSubCmd("search", "use: dws devdoc article search --query <关键词>")) diff --git a/internal/helpers/ding.go b/internal/helpers/ding.go index c3db7200..e2f0f95d 100644 --- a/internal/helpers/ding.go +++ b/internal/helpers/ding.go @@ -42,14 +42,14 @@ func newDingCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "ding", Short: "DING 消息 / 发送 / 撤回", Long: `发送和撤回 DING 消息(应用内/短信/电话)。预发环境可用。`, RunE: groupRunE, - } + }) - dingMessageCmd := &cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE} + dingMessageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "DING 消息管理", RunE: groupRunE}) dingMessageSendCmd := &cobra.Command{ Use: "send", diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index d9143df8..25fab91d 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -1166,7 +1166,7 @@ func newDocCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "doc", Short: "钉钉文档管理", Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。 @@ -1190,7 +1190,7 @@ func newDocCommand() *cobra.Command { 文件管理(搜索/列表/上传/下载/复制/移动/重命名/删除/权限)已迁移到 dws drive。`, RunE: groupRunE, - } + }) searchCmd := &cobra.Command{ Use: "search", @@ -1826,7 +1826,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 }, }) - fileCmd := &cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE} + fileCmd := newGroupCommand(&cobra.Command{Use: "file", Short: "文件管理", RunE: groupRunE}) fileCreateCmd := &cobra.Command{ Use: "create", @@ -1904,7 +1904,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 }, }) - folderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE} + folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理", RunE: groupRunE}) folderCreateCmd := &cobra.Command{ Use: "create", @@ -2063,12 +2063,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 }, }) - blockCmd := &cobra.Command{ + blockCmd := newGroupCommand(&cobra.Command{ Use: "block", Short: "块级编辑", Long: `对文档进行块级别的精细编辑:查询、插入、更新、删除块元素。`, RunE: groupRunE, - } + }) blockListCmd := &cobra.Command{ Use: "list", @@ -2844,12 +2844,12 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。 _ = renameCmd.Flags().MarkHidden("title") // ── media (文档媒体/附件) ──────────────────────────────── - mediaCmd := &cobra.Command{ + mediaCmd := newGroupCommand(&cobra.Command{ Use: "media", Short: "文档媒体 / 附件管理", Long: `管理钉钉文档中的媒体资源和附件:上传附件并插入文档、下载文档内的附件等。`, RunE: groupRunE, - } + }) mediaDownloadCmd := &cobra.Command{ Use: "download", @@ -3036,12 +3036,12 @@ resourceId 需通过 dws doc block list 获取:查询目标文档的块列表 mediaCmd.AddCommand(mediaDownloadCmd, mediaUploadCmd, mediaInsertCmd) // ── comment (文档评论) ────────────────────────────────── - commentCmd := &cobra.Command{ + commentCmd := newGroupCommand(&cobra.Command{ Use: "comment", Short: "文档评论 / 评论管理", Long: `管理钉钉文档的评论:查询评论列表、创建评论、回复评论。`, RunE: groupRunE, - } + }) commentListCmd := &cobra.Command{ Use: "list", @@ -3545,13 +3545,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 commentCmd.AddCommand(newCommentBaseCommands("doc")...) // ── permission (文档协作权限) ──────────────────────────── - permissionCmd := &cobra.Command{ + permissionCmd := newGroupCommand(&cobra.Command{ Use: "permission", Aliases: []string{"perm"}, Short: "文档协作权限管理", Long: `管理钉钉文档的协作者权限:添加协作者、更新协作者权限、查询协作者列表。`, RunE: groupRunE, - } + }) permissionAddCmd := &cobra.Command{ Use: "add", @@ -4260,6 +4260,7 @@ CLI 内部自动完成全部流程: _ = exportCmd.Flags().MarkHidden("file-id") exportCmd.AddCommand(exportGetCmd) + markGroup(exportCmd) // ── import: 文件导入为在线文档(一体化:上传→转换→轮询)────────────── importCmd := &cobra.Command{ @@ -4358,12 +4359,12 @@ CLI 内部自动完成全部流程: importCmd.AddCommand(importGetCmd) // ── doc version 子命令组 ── - versionCmd := &cobra.Command{ + versionCmd := newGroupCommand(&cobra.Command{ Use: "version", Short: "文档历史版本管理", Long: `管理钉钉在线文档(adoc)的历史版本:手动保存、查看版本列表、回滚到指定版本。`, RunE: groupRunE, - } + }) versionSaveCmd := &cobra.Command{ Use: "save", @@ -4549,7 +4550,7 @@ CLI 内部自动完成全部流程: versionCmd.AddCommand(versionSaveCmd, versionListCmd, versionRevertCmd) // ── template 子命令组 ────────────────────────────────────────────────────── - templateCmd := &cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE} + templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "文档模板管理", RunE: groupRunE}) templateListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/doc_style.go b/internal/helpers/doc_style.go index 109a3d56..45c76f50 100644 --- a/internal/helpers/doc_style.go +++ b/internal/helpers/doc_style.go @@ -20,7 +20,7 @@ const docStyleGetToolName = "get_document_style" // newDocStyleCommand 构建 `dws doc style` 命令组:cover set|clear、background set|clear、get。 func newDocStyleCommand() *cobra.Command { - styleCmd := &cobra.Command{ + styleCmd := newGroupCommand(&cobra.Command{ Use: "style", Short: "文档样式配置 (封面/背景)", Long: `配置钉钉文档的封面与背景(单接口收口 update_document_style)。 @@ -34,13 +34,13 @@ func newDocStyleCommand() *cobra.Command { 封面图片支持 --image 外链 (自动转存) 或 --file 本地文件上传,均会转存为公开读地址;背景仅支持 --color 纯色。`, RunE: groupRunE, - } + }) - coverCmd := &cobra.Command{ + coverCmd := newGroupCommand(&cobra.Command{ Use: "cover", Short: "文档封面设置/移除", RunE: groupRunE, - } + }) coverSetCmd := &cobra.Command{ Use: "set", Short: "设置文档封面", @@ -110,11 +110,11 @@ func newDocStyleCommand() *cobra.Command { }, }) - backgroundCmd := &cobra.Command{ + backgroundCmd := newGroupCommand(&cobra.Command{ Use: "background", Short: "文档背景设置/清除", RunE: groupRunE, - } + }) backgroundSetCmd := &cobra.Command{ Use: "set", Short: "设置文档背景纯色", diff --git a/internal/helpers/doc_whiteboard.go b/internal/helpers/doc_whiteboard.go index 2ca8e224..3416aaf4 100644 --- a/internal/helpers/doc_whiteboard.go +++ b/internal/helpers/doc_whiteboard.go @@ -207,12 +207,12 @@ func runWhiteboardInsert(cmd *cobra.Command, _ []string) error { } func newDocWhiteboardCommand() *cobra.Command { - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "whiteboard", Short: "白板卡片管理", Long: `管理钉钉文档中的白板卡片:插入空白板并获取白板资源 ID。删除白板卡片请使用 dws doc block delete。`, RunE: groupRunE, - } + }) insertCmd := &cobra.Command{ Use: "insert", diff --git a/internal/helpers/drive.go b/internal/helpers/drive.go index b42c45ba..e91135a7 100644 --- a/internal/helpers/drive.go +++ b/internal/helpers/drive.go @@ -375,12 +375,12 @@ func newDriveCommand() *cobra.Command { }, }, }) - driveCmd := &cobra.Command{ + driveCmd := newGroupCommand(&cobra.Command{ Use: "drive", Short: "钉盘文件管理", Long: `钉盘:列出文件/文件夹、获取元数据和统计信息、创建快捷方式、下载、上传及管理文件。`, RunE: groupRunE, - } + }) driveListCmd := &cobra.Command{ Use: "list", @@ -2030,14 +2030,14 @@ func newDriveCommand() *cobra.Command { driveShortcutCmd.Flags().String("workspace", "", "目标知识库 ID (可选)") // ── drive permission (文档节点权限管理) ── - drivePermissionCmd := &cobra.Command{ + drivePermissionCmd := newGroupCommand(&cobra.Command{ Use: "permission", Aliases: []string{"perm"}, Short: "文档节点权限管理", Long: `管理文档空间节点的协作权限:添加、更新、查询、移除协作者。 注意: 仅适用于文档空间节点,不适用于钉盘文件。`, RunE: groupRunE, - } + }) drivePermAddCmd := &cobra.Command{ Use: "add", @@ -2806,12 +2806,12 @@ func newDriveCommand() *cobra.Command { _ = driveRenameCmd.Flags().MarkHidden("title") // ── drive recycle 子命令组 ── - recycleCmd := &cobra.Command{ + recycleCmd := newGroupCommand(&cobra.Command{ Use: "recycle", Short: "钉盘回收站管理", Long: `管理钉盘回收站:查看回收站列表、还原回收项。`, RunE: groupRunE, - } + }) recycleListCmd := &cobra.Command{ Use: "list", @@ -2921,7 +2921,7 @@ func newDriveCommand() *cobra.Command { // ── deprecated 代理命令(Phase 2:从 doc 迁移,保留兼容,警告引导到新命令)── // folder create → dws wiki node create --type folder - driveFolderCmd := &cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE} + driveFolderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "文件夹管理(deprecated)", RunE: groupRunE}) driveFolderCreateCmd := &cobra.Command{ Use: "create", Short: "创建文件夹(deprecated)", @@ -2951,12 +2951,12 @@ func newDriveCommand() *cobra.Command { driveFolderCmd.AddCommand(driveFolderCreateCmd) // ── drive publish (文件互联网公开发布管理) ── - drivePublishCmd := &cobra.Command{ + drivePublishCmd := newGroupCommand(&cobra.Command{ Use: "publish", Short: "文件互联网公开发布管理", Long: `管理文件的互联网公开发布状态:设置公开、关闭公开、查询公开状态。`, RunE: groupRunE, - } + }) drivePublishSetCmd := &cobra.Command{ Use: "set", @@ -3251,11 +3251,11 @@ func newDriveCommand() *cobra.Command { _ = driveRecentCmd.Flags().MarkHidden("page-token") // ── drive star (文档收藏管理) ── - driveStarCmd := &cobra.Command{ + driveStarCmd := newGroupCommand(&cobra.Command{ Use: "star", Short: "文档收藏管理", RunE: groupRunE, - } + }) driveStarAddCmd := &cobra.Command{ Use: "add", Short: "收藏文档", diff --git a/internal/helpers/file_comment.go b/internal/helpers/file_comment.go index f573de94..425f2224 100644 --- a/internal/helpers/file_comment.go +++ b/internal/helpers/file_comment.go @@ -55,12 +55,12 @@ func fileCommentSpaceIDFlag() LeafFlag { // doc comment, sheet comment, and drive comment. The public command belongs to // Drive, while the implementation routes to the shared doc-comment MCP server. func newDriveFileCommentCmd() *cobra.Command { - commentCmd := &cobra.Command{ + commentCmd := newGroupCommand(&cobra.Command{ Use: "comment", Short: "普通文件评论管理", Long: "管理钉盘普通预览文件的评论:查询评论列表或创建全文纯文本评论。", RunE: groupRunE, - } + }) listCmd := NewLeafCommand(LeafSpec{ Use: "list", diff --git a/internal/helpers/group_command_declaration_test.go b/internal/helpers/group_command_declaration_test.go new file mode 100644 index 00000000..c0349f58 --- /dev/null +++ b/internal/helpers/group_command_declaration_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package helpers + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "sort" + "strings" + "testing" +) + +func TestCrossPlatformCoverageGroupRunEDeclarationsUseExplicitGroupConstructor(t *testing.T) { + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read helpers directory: %v", err) + } + + fset := token.NewFileSet() + var violations []string + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, parseErr := parser.ParseFile(fset, name, nil, parser.ParseComments) + if parseErr != nil { + t.Fatalf("parse %s: %v", name, parseErr) + } + // 生成文件必须通过其生成器维护,不能由机械迁移直接改写。 + if ast.IsGenerated(file) { + continue + } + + wrapped := explicitGroupCommandLiterals(file) + ast.Inspect(file, func(node ast.Node) bool { + literal, ok := node.(*ast.CompositeLit) + if !ok || !declaresGroupRunE(literal) { + return true + } + if _, ok := wrapped[literal]; !ok { + position := fset.Position(literal.Pos()) + violations = append(violations, position.String()) + } + return true + }) + } + + if len(violations) == 0 { + return + } + sort.Strings(violations) + shown := violations + if len(shown) > 10 { + shown = shown[:10] + } + t.Fatalf("%d groupRunE declarations bypass newGroupCommand; first %d:\n%s", + len(violations), len(shown), strings.Join(shown, "\n")) +} + +func explicitGroupCommandLiterals(file *ast.File) map[*ast.CompositeLit]struct{} { + wrapped := make(map[*ast.CompositeLit]struct{}) + ast.Inspect(file, func(node ast.Node) bool { + call, ok := node.(*ast.CallExpr) + if !ok || len(call.Args) != 1 { + return true + } + callee, ok := call.Fun.(*ast.Ident) + if !ok || callee.Name != "newGroupCommand" { + return true + } + argument, ok := call.Args[0].(*ast.UnaryExpr) + if !ok || argument.Op != token.AND { + return true + } + literal, ok := argument.X.(*ast.CompositeLit) + if ok { + wrapped[literal] = struct{}{} + } + return true + }) + return wrapped +} + +func declaresGroupRunE(literal *ast.CompositeLit) bool { + for _, element := range literal.Elts { + field, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + key, keyOK := field.Key.(*ast.Ident) + value, valueOK := field.Value.(*ast.Ident) + if keyOK && valueOK && key.Name == "RunE" && value.Name == "groupRunE" { + return true + } + } + return false +} diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 986e4df4..5afa7a2b 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -16,17 +16,19 @@ import ( "github.com/spf13/pflag" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) -// Re-export cmdutil functions as package-level aliases so that existing product -// files continue to compile with their current (unexported) call sites. -// This avoids a mass-rename in 22 product files while still consolidating the -// implementations in pkg/cmdutil. +// Re-export shared command helpers as package-level aliases so existing product +// files continue to compile with their current (unexported) call sites. This +// avoids a mass-rename while keeping command-error classification in pipeline +// and the remaining reusable utilities in cmdutil. var ( - groupRunE = cmdutil.GroupRunE - hintSubCmd = cmdutil.HintSubCmd + groupRunE = pipeline.GroupRunE + hintSubCmd = pipeline.HintSubCmd + markGroup = cmdutil.MarkGroup mustGetFlag = cmdutil.MustGetFlag flagOrFallback = cmdutil.FlagOrFallback mustFlagOrFallback = cmdutil.MustFlagOrFallback @@ -38,6 +40,14 @@ var ( helperAfter = time.After ) +// newGroupCommand declares a navigation-only command container. The explicit +// identity lets pre-parse command recovery take precedence over child flags +// without inferring group semantics from the shape of the Cobra tree. +func newGroupCommand(command *cobra.Command) *cobra.Command { + markGroup(command) + return command +} + // Deps holds shared dependencies injected from the host application. type Deps struct { Caller edition.ToolCaller diff --git a/internal/helpers/hrbrain.go b/internal/helpers/hrbrain.go index 79a7e42b..4a60164a 100644 --- a/internal/helpers/hrbrain.go +++ b/internal/helpers/hrbrain.go @@ -29,7 +29,7 @@ func newHrbrainCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "hrbrain", Short: "组织大脑:人才池、员工档案与人才搜索", Long: `钉钉组织大脑(hrbrain)能力:人才池管理、员工档案查询、人才搜索与标签管理。 @@ -47,11 +47,11 @@ func newHrbrainCommand() *cobra.Command { dws hrbrain search employees-structured 使用高级条件搜索人员 dws hrbrain search fields 获取高级搜索字段列表`, RunE: groupRunE, - } + }) // ── talent-pool: 人才池管理 ──────────────────────────────── - talentPoolCmd := &cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE} + talentPoolCmd := newGroupCommand(&cobra.Command{Use: "talent-pool", Short: "人才池管理", RunE: groupRunE}) talentPoolListCmd := &cobra.Command{ Use: "list", @@ -236,7 +236,7 @@ func newHrbrainCommand() *cobra.Command { // ── profile: 员工档案管理 ────────────────────────────────── - profileCmd := &cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE} + profileCmd := newGroupCommand(&cobra.Command{Use: "profile", Short: "员工档案管理", RunE: groupRunE}) profileMetadataCmd := &cobra.Command{ Use: "metadata", @@ -493,7 +493,7 @@ func newHrbrainCommand() *cobra.Command { // ── search: 人才搜索 ───────────────────────────────────── - searchCmd := &cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE} + searchCmd := newGroupCommand(&cobra.Command{Use: "search", Short: "人才搜索", RunE: groupRunE}) employeeSearchCmd := &cobra.Command{ Use: "employees", diff --git a/internal/helpers/interfaces.go b/internal/helpers/interfaces.go index 062b78e7..19862dd2 100644 --- a/internal/helpers/interfaces.go +++ b/internal/helpers/interfaces.go @@ -21,6 +21,7 @@ import ( "sync" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -72,7 +73,12 @@ func buildCommands(factories []Factory, runner executor.Runner) []*cobra.Command out := make([]*cobra.Command, 0, len(factories)) for _, factory := range factories { handler := factory() - out = append(out, handler.Command(runner)) + command := handler.Command(runner) + // A public helper Factory owns one product root. Product roots are + // positional-free command containers even when they also provide a + // flag-driven default action (for example aisearch). + cmdutil.MarkGroup(command) + out = append(out, command) } sort.Slice(out, func(i, j int) bool { return out[i].Use < out[j].Use diff --git a/internal/helpers/live.go b/internal/helpers/live.go index 9e6266ab..bb18fc7c 100644 --- a/internal/helpers/live.go +++ b/internal/helpers/live.go @@ -20,14 +20,14 @@ func newLiveCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "live", Short: "直播列表 / 信息", Long: `查看钉钉直播:列出我的直播记录。`, RunE: groupRunE, - } + }) - streamCmd := &cobra.Command{Use: "stream", Short: "直播流管理", RunE: groupRunE} + streamCmd := newGroupCommand(&cobra.Command{Use: "stream", Short: "直播流管理", RunE: groupRunE}) streamListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/mail.go b/internal/helpers/mail.go index 30793fd2..3c5fa5e1 100644 --- a/internal/helpers/mail.go +++ b/internal/helpers/mail.go @@ -113,14 +113,14 @@ func newMailCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "mail", Short: "邮箱 / 邮件收发", Long: `管理钉钉企业邮箱:查询邮箱地址、搜索邮件、查看邮件、发送邮件、获取会话(thread)、列举文件夹、列举标签。`, RunE: groupRunE, - } + }) - mailboxCmd := &cobra.Command{Use: "mailbox", Short: "邮箱地址管理", RunE: groupRunE} + mailboxCmd := newGroupCommand(&cobra.Command{Use: "mailbox", Short: "邮箱地址管理", RunE: groupRunE}) mailboxListCmd := &cobra.Command{ Use: "list", @@ -263,7 +263,7 @@ func newMailCommand() *cobra.Command { mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd) - messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE} + messageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}) messageSearchCmd := &cobra.Command{ Use: "search", @@ -588,7 +588,7 @@ func newMailCommand() *cobra.Command { }, }) - folderCmd := &cobra.Command{Use: "folder", Short: "邮件文件夹管理", RunE: groupRunE} + folderCmd := newGroupCommand(&cobra.Command{Use: "folder", Short: "邮件文件夹管理", RunE: groupRunE}) folderListCmd := &cobra.Command{ Use: "list", @@ -842,7 +842,7 @@ func newMailCommand() *cobra.Command { folderCmd.AddCommand(folderListCmd, folderCreateCmd, folderDeleteCmd, folderUpdateCmd) - tagCmd := &cobra.Command{Use: "tag", Short: "邮件标签管理", RunE: groupRunE} + tagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "邮件标签管理", RunE: groupRunE}) tagListCmd := &cobra.Command{ Use: "list", @@ -995,7 +995,7 @@ func newMailCommand() *cobra.Command { tagCmd.AddCommand(tagListCmd, tagCreateCmd, tagDeleteCmd, tagUpdateCmd) - threadCmd := &cobra.Command{Use: "thread", Short: "邮件会话管理", RunE: groupRunE} + threadCmd := newGroupCommand(&cobra.Command{Use: "thread", Short: "邮件会话管理", RunE: groupRunE}) threadListCmd := &cobra.Command{ Use: "list", @@ -1975,7 +1975,7 @@ internetMessageId 来源:message send / draft send / message reply / message r }, }) - attachmentCmd := &cobra.Command{Use: "attachment", Short: "邮件附件管理", RunE: groupRunE} + attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "邮件附件管理", RunE: groupRunE}) attachmentListCmd := &cobra.Command{ Use: "list", @@ -2443,7 +2443,7 @@ internetMessageId 来源:message send / draft send / message reply / message r messageReplyCmd, messageReplyAllCmd, messageForwardCmd, messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd) - sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE} + sentMessageCmd := newGroupCommand(&cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}) sentMessageRecallCmd := &cobra.Command{ Use: "recall", @@ -2600,10 +2600,10 @@ internetMessageId 来源:message send / draft send / message reply / message r _ = draftSendCmd.Flags().MarkHidden("sender") draftSendCmd.Flags().String("id", "", "草稿邮件 ID (必填)") - draftCmd := &cobra.Command{Use: "draft", Short: "草稿管理", RunE: groupRunE} + draftCmd := newGroupCommand(&cobra.Command{Use: "draft", Short: "草稿管理", RunE: groupRunE}) draftCmd.AddCommand(draftCreateCmd, draftUpdateCmd, draftSendCmd) - userCmd := &cobra.Command{Use: "user", Short: "邮箱用户管理", RunE: groupRunE} + userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "邮箱用户管理", RunE: groupRunE}) userSearchCmd := &cobra.Command{ Use: "search", @@ -2707,7 +2707,7 @@ user 对象字段: // ── template 子命令组 ────────────────────────────────── - templateCmd := &cobra.Command{Use: "template", Short: "邮件模板管理", RunE: groupRunE} + templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "邮件模板管理", RunE: groupRunE}) templateCreateCmd := &cobra.Command{ Use: "create", @@ -3057,7 +3057,7 @@ user 对象字段: // ── contact 子命令组 ────────────────────────────────── - contactCmd := &cobra.Command{Use: "contact", Short: "邮件联系人管理", RunE: groupRunE} + contactCmd := newGroupCommand(&cobra.Command{Use: "contact", Short: "邮件联系人管理", RunE: groupRunE}) contactCreateCmd := &cobra.Command{ Use: "create", @@ -3315,7 +3315,7 @@ user 对象字段: contactCmd.AddCommand(contactCreateCmd, contactListCmd, contactUpdateCmd, contactBatchDeleteCmd) // ── auto-reply 自动回复 ────────────────────────────── - autoReplyCmd := &cobra.Command{Use: "auto-reply", Short: "邮件自动回复管理", RunE: groupRunE} + autoReplyCmd := newGroupCommand(&cobra.Command{Use: "auto-reply", Short: "邮件自动回复管理", RunE: groupRunE}) autoReplyGetCmd := &cobra.Command{ Use: "get", @@ -3393,7 +3393,7 @@ user 对象字段: autoReplyCmd.AddCommand(autoReplyGetCmd, autoReplyUpdateCmd) // ── rule 收信规则 ──────────────────────────────────── - ruleCmd := &cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE} + ruleCmd := newGroupCommand(&cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE}) ruleListCmd := &cobra.Command{ Use: "list", @@ -3578,7 +3578,7 @@ object 与 operation 合法组合: ruleCmd.AddCommand(ruleListCmd, ruleCreateCmd, ruleUpdateCmd, ruleDeleteCmd, ruleAdjustCmd) // ── allow-list 个人收信白名单 ──────────────────────────────── - allowListCmd := &cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE} + allowListCmd := newGroupCommand(&cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE}) allowListListCmd := &cobra.Command{ Use: "list", @@ -3646,7 +3646,7 @@ object 与 operation 合法组合: allowListCmd.AddCommand(allowListListCmd, allowListAddCmd, allowListRemoveCmd) // ── block-list 个人收信黑名单 ──────────────────────────────── - blockListCmd := &cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE} + blockListCmd := newGroupCommand(&cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE}) blockListListCmd := &cobra.Command{ Use: "list", @@ -3713,7 +3713,7 @@ object 与 operation 合法组合: blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)") blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd) - calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE} + calendarCmd := newGroupCommand(&cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}) calendarListCmd := &cobra.Command{ Use: "list", Short: "列出用户可访问的日历列表", @@ -3762,7 +3762,7 @@ object 与 operation 合法组合: calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)") calendarCmd.AddCommand(calendarListCmd) - calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE} + calendarEventCmd := newGroupCommand(&cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}) calendarEventListCmd := &cobra.Command{ Use: "list", Short: "查询指定日历时间范围内的日程", diff --git a/internal/helpers/markdown.go b/internal/helpers/markdown.go index 505126f6..d38943d8 100644 --- a/internal/helpers/markdown.go +++ b/internal/helpers/markdown.go @@ -46,12 +46,12 @@ func newMarkdownCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "markdown", Short: "Markdown 文件处理", Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。", RunE: groupRunE, - } + }) root.AddCommand( newMarkdownFetchCmd(), newMarkdownCreateCmd(), diff --git a/internal/helpers/minutes.go b/internal/helpers/minutes.go index e3f11ce8..f843ef73 100644 --- a/internal/helpers/minutes.go +++ b/internal/helpers/minutes.go @@ -28,7 +28,7 @@ func newMinutesCommand() *cobra.Command { }, }, }) - minutesListCmd := &cobra.Command{Use: "list", Short: "听记列表", RunE: groupRunE} + minutesListCmd := newGroupCommand(&cobra.Command{Use: "list", Short: "听记列表", RunE: groupRunE}) minutesListMineCmd := &cobra.Command{ Use: "mine", @@ -173,7 +173,7 @@ func newMinutesCommand() *cobra.Command { }, }) - minutesGetCmd := &cobra.Command{Use: "get", Short: "获取听记内容", RunE: groupRunE} + minutesGetCmd := newGroupCommand(&cobra.Command{Use: "get", Short: "获取听记内容", RunE: groupRunE}) minutesGetInfoCmd := &cobra.Command{ Use: "info", @@ -544,7 +544,7 @@ func newMinutesCommand() *cobra.Command { }, }) - minutesUpdateCmd := &cobra.Command{Use: "update", Short: "更新听记信息", RunE: groupRunE} + minutesUpdateCmd := newGroupCommand(&cobra.Command{Use: "update", Short: "更新听记信息", RunE: groupRunE}) minutesUpdateTitleCmd := &cobra.Command{ Use: "title", @@ -659,7 +659,7 @@ func newMinutesCommand() *cobra.Command { // 单个工具通过 cmd 参数覆盖 create/pause/resume/end 四种指令。 const listeningNoteCmdTool = "执行听记指令-发起AI听记录音" - minutesRecordCmd := &cobra.Command{Use: "record", Short: "控制听记录音", RunE: groupRunE} + minutesRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "控制听记录音", RunE: groupRunE}) minutesRecordStartCmd := &cobra.Command{ Use: "start", @@ -942,7 +942,7 @@ func newMinutesCommand() *cobra.Command { minutesUpdateCmd.AddCommand(minutesUpdateTitleCmd, minutesUpdateSummaryCmd) // ── mind-graph 子组 ───────────────────────────────────────── - mindGraphCmd := &cobra.Command{Use: "mind-graph", Short: "思维导图管理", RunE: groupRunE} + mindGraphCmd := newGroupCommand(&cobra.Command{Use: "mind-graph", Short: "思维导图管理", RunE: groupRunE}) mindGraphCreateCmd := &cobra.Command{ Use: "create", @@ -1059,7 +1059,7 @@ func newMinutesCommand() *cobra.Command { mindGraphCmd.AddCommand(mindGraphCreateCmd, mindGraphStatusCmd) // ── speaker 子组 ──────────────────────────────────────────── - speakerCmd := &cobra.Command{Use: "speaker", Short: "发言人管理", RunE: groupRunE} + speakerCmd := newGroupCommand(&cobra.Command{Use: "speaker", Short: "发言人管理", RunE: groupRunE}) speakerReplaceCmd := &cobra.Command{ Use: "replace", @@ -1127,7 +1127,7 @@ func newMinutesCommand() *cobra.Command { // 对应 MCP 工具 create_speaker_summary / get_speaker_summary // 批量按听记维度汇总每位发言人的段落总结 - speakerSummaryCmd := &cobra.Command{Use: "summary", Short: "发言人段落总结", RunE: groupRunE} + speakerSummaryCmd := newGroupCommand(&cobra.Command{Use: "summary", Short: "发言人段落总结", RunE: groupRunE}) speakerSummaryCreateCmd := &cobra.Command{ Use: "create", @@ -1243,7 +1243,7 @@ func newMinutesCommand() *cobra.Command { speakerCmd.AddCommand(speakerReplaceCmd, speakerSummaryCmd) // ── hot-word 子组 ─────────────────────────────────────────── - hotWordCmd := &cobra.Command{Use: "hot-word", Short: "个人热词管理", RunE: groupRunE} + hotWordCmd := newGroupCommand(&cobra.Command{Use: "hot-word", Short: "个人热词管理", RunE: groupRunE}) hotWordAddCmd := &cobra.Command{ Use: "add", @@ -1462,7 +1462,7 @@ func newMinutesCommand() *cobra.Command { // 完整流程:create → HTTP PUT → complete,或 create → cancel 取消。 // 注意:upload 子组的所有命令均使用 callMCPToolUnescaped 输出 JSON, // 避免 presignedUrl 中的 & 被 Go 标准库转义为 \u0026。 - uploadCmd := &cobra.Command{Use: "upload", Short: "文件上传管理", RunE: groupRunE} + uploadCmd := newGroupCommand(&cobra.Command{Use: "upload", Short: "文件上传管理", RunE: groupRunE}) // upload create — 对应 MCP 工具 create_upload_session // 必填参数:fileName(--file-name), fileSize(--file-size) @@ -1681,7 +1681,7 @@ func newMinutesCommand() *cobra.Command { // ── permission 子组 ───────────────────────────────────────── // 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。 // 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。 - permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE} + permissionCmd := newGroupCommand(&cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}) // permission add — 对应 MCP 工具 add_member_permission // 批量给多个听记增加成员,并设置成员的权限。 @@ -1951,7 +1951,7 @@ func newMinutesCommand() *cobra.Command { // 听记标签/分组管理:查询用户标签列表、按标签查询听记。 // 对应 MCP 工具 query_user_tag_list / query_minutes_by_tag_id。 // 标签/分组由用户在听记页面手动创建,此处仅提供查询能力。 - tagCmd := &cobra.Command{Use: "tag", Short: "听记标签/分组管理", RunE: groupRunE} + tagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "听记标签/分组管理", RunE: groupRunE}) // tag list — 对应 MCP 工具 query_user_tag_list // 无需传入参数,系统自动识别当前用户身份。 @@ -2075,7 +2075,7 @@ func newMinutesCommand() *cobra.Command { // 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。 // 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用 // callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。 - audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE} + audioMemoCmd := newGroupCommand(&cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}) audioMemoListCmd := &cobra.Command{ Use: "list", @@ -2179,12 +2179,12 @@ func newMinutesCommand() *cobra.Command { audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)") audioMemoCmd.AddCommand(audioMemoListCmd) - minutesCmd := &cobra.Command{ + minutesCmd := newGroupCommand(&cobra.Command{ Use: "minutes", Short: "AI 听记 / 会议纪要", Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`, RunE: groupRunE, - } + }) minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd) return minutesCmd } diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index c35c41cc..38e5a49f 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -369,11 +369,11 @@ func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { } func newOAAttachmentCommand() *cobra.Command { - attachmentCmd := &cobra.Command{ + attachmentCmd := newGroupCommand(&cobra.Command{ Use: "attachment", Short: "审批附件授权、上传、下载与链接管理", RunE: groupRunE, - } + }) downloadURLCmd := NewLeafCommand(LeafSpec{ Use: "download-url", @@ -724,14 +724,14 @@ func newOaCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "oa", Short: "OA 审批 / 同意 / 拒绝 / 撤销", Long: `管理钉钉 OA 审批:待办查询、审批详情、同意、拒绝、撤销、操作记录、已发起列表、表单列表与附件授权。`, RunE: groupRunE, - } + }) - approvalCmd := &cobra.Command{Use: "approval", Short: "审批管理", RunE: groupRunE} + approvalCmd := newGroupCommand(&cobra.Command{Use: "approval", Short: "审批管理", RunE: groupRunE}) approvalListPendingCmd := &cobra.Command{ Use: "list-pending", diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go index cb2979c9..3f8e2bb3 100644 --- a/internal/helpers/recruit.go +++ b/internal/helpers/recruit.go @@ -61,17 +61,17 @@ func newRecruitCommand() *cobra.Command { }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "recruit", Short: "钉钉招聘", Long: "查询和创建钉钉招聘中的职位信息。", RunE: groupRunE, - } - job := &cobra.Command{ + }) + job := newGroupCommand(&cobra.Command{ Use: "job", Short: "招聘职位管理", RunE: groupRunE, - } + }) job.AddCommand( newRecruitJobListCommand(), newRecruitJobGetCommand(), diff --git a/internal/helpers/report.go b/internal/helpers/report.go index fffbb505..f0282b1a 100644 --- a/internal/helpers/report.go +++ b/internal/helpers/report.go @@ -64,7 +64,7 @@ func newReportCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "report", Aliases: []string{"log"}, Short: "钉钉日志(OA 周报应用 / 日志模版填报)", @@ -84,10 +84,10 @@ func newReportCommand() *cobra.Command { 别名:dws log 等价 dws report(注意:此处 log 特指 OA 周报应用,不是通用日志/记录)。`, RunE: groupRunE, - } + }) // === template subtree(template list 不变;新增 template get;template detail 转 deprecated alias)=== - templateCmd := &cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE} + templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "日志模版", RunE: groupRunE}) templateListCmd := &cobra.Command{ Use: "list", @@ -179,7 +179,7 @@ func newReportCommand() *cobra.Command { templateCmd.AddCommand(templateListCmd, templateGetCmd, templateDetailCmd) // === entry subtree(单条日报操作 — get / stats / submit)=== - entryCmd := &cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE} + entryCmd := newGroupCommand(&cobra.Command{Use: "entry", Short: "日志条目(单条日报操作 — get / stats / submit)", RunE: groupRunE}) entryGetCmd := &cobra.Command{ Use: "get", @@ -386,9 +386,10 @@ func newReportCommand() *cobra.Command { addReportListFlags(inboxListCmd) inboxCmd.AddCommand(inboxListCmd) + markGroup(inboxCmd) // === outbox subtree(我发出的日报)=== - outboxCmd := &cobra.Command{Use: "outbox", Short: "发件箱(我发出的日报)", RunE: groupRunE} + outboxCmd := newGroupCommand(&cobra.Command{Use: "outbox", Short: "发件箱(我发出的日报)", RunE: groupRunE}) outboxListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index 682c4f4f..67865188 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -2,9 +2,12 @@ package helpers import ( "fmt" + "sort" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -328,8 +331,8 @@ func attachSheetConfirmationGuard(root *cobra.Command, path, operation, targetHi // 未知 flag 连同其值一起被静默消化; // 2. Args=ArbitraryArgs —— 允许把剩余位置参数交给 RunE 处理; // 3. RunE —— 取 args[0] 作为拼错的子命令名,先在后代命令里查找完全同名的叶子 -// (能把 `sheet read` 精准引导到 `sheet range read`),找不到再退回 cobra -// 自带的同级编辑距离建议;最终返回 error 以保证 exit!=0。 +// (能把 `sheet read` 精准引导到 `sheet range read`),找不到再退回共享的 +// 有界同级编辑距离建议;最终返回 error 以保证 exit!=0。 // // 仅挂在分组型父命令(sheet/range/filter-view)上,不会影响已在 cobra Find 阶段 // 精确匹配到的合法叶子命令。 @@ -347,20 +350,26 @@ func attachUnknownSubcommandGuard(cmd *cobra.Command) { return c.Help() } name := args[0] - var buf strings.Builder - fmt.Fprintf(&buf, "unknown command %q for %q", name, c.CommandPath()) suggestions := deepSuggestSubcommand(c, name) if len(suggestions) == 0 { - suggestions = c.SuggestionsFor(name) - } - if len(suggestions) > 0 { - buf.WriteString("\n\nDid you mean this?") - for _, s := range suggestions { - fmt.Fprintf(&buf, "\n\t%s %s", c.CommandPath(), s) + suggestions = cmdutil.SuggestSubcommands(c, name) + } else { + sort.Strings(suggestions) + if len(suggestions) > cmdutil.MaxCommandSuggestions { + suggestions = suggestions[:cmdutil.MaxCommandSuggestions] } } - fmt.Fprintf(&buf, "\n\nRun '%s --help' for usage.", c.CommandPath()) - return fmt.Errorf("%s", buf.String()) + action := fmt.Sprintf("Run '%s --help' for the full list", c.CommandPath()) + return apperrors.NewValidation( + fmt.Sprintf("unknown subcommand %q for %q", name, c.CommandPath()), + apperrors.WithReason("unknown_subcommand"), + apperrors.WithHint(cmdutil.FormatSubcommandSuggestionHint(c, suggestions, action)), + apperrors.WithActions(action), + apperrors.WithDetails(map[string]any{ + "input": name, + "suggestions": suggestions, + }), + ) } } diff --git a/internal/helpers/sheet_comment.go b/internal/helpers/sheet_comment.go index b16ddf33..ddc961fe 100644 --- a/internal/helpers/sheet_comment.go +++ b/internal/helpers/sheet_comment.go @@ -7,12 +7,12 @@ import ( ) func newSheetCommentCmd() *cobra.Command { - commentCmd := &cobra.Command{ + commentCmd := newGroupCommand(&cobra.Command{ Use: "comment", Short: "表格评论 / 单元格评论管理", Long: `管理钉钉表格的单元格评论:查询评论列表、创建评论、回复评论、更新评论、删除评论。`, RunE: groupRunE, - } + }) commentListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/sheet_pivot_table.go b/internal/helpers/sheet_pivot_table.go index ac1a07bc..638bfe91 100644 --- a/internal/helpers/sheet_pivot_table.go +++ b/internal/helpers/sheet_pivot_table.go @@ -134,11 +134,11 @@ func readPivotProperties(raw string, requireValues bool) (map[string]any, error) } func newPivotTableCmd() *cobra.Command { - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "pivot-table", Short: "透视表管理", RunE: groupRunE, - } + }) listCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/sheet_template.go b/internal/helpers/sheet_template.go index ee0de5dd..71063c1c 100644 --- a/internal/helpers/sheet_template.go +++ b/internal/helpers/sheet_template.go @@ -9,11 +9,11 @@ import ( // newSheetTemplateCmd builds the `dws sheet template` command group. func newSheetTemplateCmd() *cobra.Command { - templateCmd := &cobra.Command{ + templateCmd := newGroupCommand(&cobra.Command{ Use: "template", Short: "表格模板管理", RunE: groupRunE, - } + }) templateListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/sheet_version.go b/internal/helpers/sheet_version.go index a1a8c7c4..70692e56 100644 --- a/internal/helpers/sheet_version.go +++ b/internal/helpers/sheet_version.go @@ -8,12 +8,12 @@ import ( ) func newSheetVersionCmd() *cobra.Command { - versionCmd := &cobra.Command{ + versionCmd := newGroupCommand(&cobra.Command{ Use: "version", Short: "表格历史版本管理", Long: `管理钉钉在线电子表格的历史版本:手动保存、查看版本列表、回滚到指定版本。`, RunE: groupRunE, - } + }) versionSaveCmd := &cobra.Command{ Use: "save", diff --git a/internal/helpers/small_remaining_coverage_test.go b/internal/helpers/small_remaining_coverage_test.go index d34b57eb..3f73cd70 100644 --- a/internal/helpers/small_remaining_coverage_test.go +++ b/internal/helpers/small_remaining_coverage_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -21,8 +22,10 @@ func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T group.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) root.AddCommand(group) attachUnknownSubcommandGuard(root) - if err := root.RunE(root, []string{"read"}); err == nil || !strings.Contains(err.Error(), "range read") { - t.Fatalf("deep suggestion err=%v", err) + err := root.RunE(root, []string{"read"}) + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, "range read") { + t.Fatalf("deep suggestion err=%#v", err) } installScriptedCaller(t, &scriptedToolCaller{dry: true}) diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index 641b17b1..ca488373 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -69,14 +69,14 @@ func newTodoCommand() *cobra.Command { }, }, }) - todoCmd := &cobra.Command{ + todoCmd := newGroupCommand(&cobra.Command{ Use: "todo", Short: "待办任务管理", Long: `管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。`, RunE: groupRunE, - } + }) - todoTaskCmd := &cobra.Command{Use: "task", Short: "创建 / 查询 / 更新 / 删除待办", RunE: groupRunE} + todoTaskCmd := newGroupCommand(&cobra.Command{Use: "task", Short: "创建 / 查询 / 更新 / 删除待办", RunE: groupRunE}) todoTaskCreateCmd := &cobra.Command{ Use: "create", @@ -1303,7 +1303,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 // dws todo comment — 待办评论 // 对应 MCP:add_todo_comment / list_todo_comment / delete_todo_comment // ────────────────────────────────────────────────────────── - todoCommentCmd := &cobra.Command{Use: "comment", Short: "待办评论:新增 / 列表 / 删除", RunE: groupRunE} + todoCommentCmd := newGroupCommand(&cobra.Command{Use: "comment", Short: "待办评论:新增 / 列表 / 删除", RunE: groupRunE}) todoCommentAddCmd := &cobra.Command{ Use: "add", @@ -1474,7 +1474,7 @@ reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作 // dws todo tag — 待办标签 // 对应 MCP:tag_todo / delete_todo_tag / update_todo_tag / list_todo_tags / create_todo_tag // ────────────────────────────────────────────────────────── - todoTagCmd := &cobra.Command{Use: "tag", Short: "待办标签:打标 / 列表 / 创建 / 更新 / 删除", RunE: groupRunE} + todoTagCmd := newGroupCommand(&cobra.Command{Use: "tag", Short: "待办标签:打标 / 列表 / 创建 / 更新 / 删除", RunE: groupRunE}) todoTagAddCmd := &cobra.Command{ Use: "add", diff --git a/internal/helpers/whiteboard.go b/internal/helpers/whiteboard.go index 9ecdc388..7a731c0c 100644 --- a/internal/helpers/whiteboard.go +++ b/internal/helpers/whiteboard.go @@ -45,7 +45,7 @@ func newWhiteboardCommand() *cobra.Command { AvoidWhen: []string{"普通文档正文和块使用 doc;创建白板卡片先用 doc whiteboard insert"}, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "whiteboard", Short: "钉钉文档内嵌白板管理", Long: `读取或更新钉钉在线文档中已经存在的内嵌白板。 @@ -53,7 +53,7 @@ func newWhiteboardCommand() *cobra.Command { 当前仅支持单页白板。每次操作都必须同时提供文档 ID 或 URL 和白板 part ID; 本命令不负责创建白板(请使用 dws doc whiteboard insert),也不支持通过已有节点 ID 做局部修改。`, RunE: groupRunE, - } + }) queryCmd := &cobra.Command{ Use: "query", diff --git a/internal/helpers/wiki.go b/internal/helpers/wiki.go index 9fc381f1..4f81c74e 100644 --- a/internal/helpers/wiki.go +++ b/internal/helpers/wiki.go @@ -160,14 +160,14 @@ func newWikiCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "wiki", Short: "知识库 / 空间管理 / 节点管理 / 成员管理 / 动态查询", Long: `管理钉钉文档知识库:空间管理(创建/查看/列出/搜索/删除)、节点管理(列出/创建/复制/移动/删除)、成员管理(添加/更新/列出/移除)、动态查询(知识库活动动态)。`, RunE: groupRunE, - } + }) - spaceCmd := &cobra.Command{Use: "space", Short: "知识库管理", RunE: groupRunE} + spaceCmd := newGroupCommand(&cobra.Command{Use: "space", Short: "知识库管理", RunE: groupRunE}) spaceCreateCmd := &cobra.Command{ Use: "create", @@ -519,12 +519,12 @@ func newWikiCommand() *cobra.Command { spaceCmd.AddCommand(spaceCreateCmd, spaceGetCmd, spaceListCmd, spaceSearchCmd, spaceDeleteCmd) // ── member (知识库成员管理) ─────────────────────────────── - memberCmd := &cobra.Command{ + memberCmd := newGroupCommand(&cobra.Command{ Use: "member", Short: "知识库成员管理", Long: `管理钉钉知识库的成员:添加成员、更新成员权限、查询成员列表、移除成员。`, RunE: groupRunE, - } + }) memberAddCmd := &cobra.Command{ Use: "add", @@ -954,12 +954,12 @@ ORG 类型授权不会出现在查询结果中。`, // ── node (知识库节点管理) ───────────────────────────────── // 对齐飞书 cli-lark wiki node 设计:内建 list/create/copy/move/delete, // 一跳直达 doc MCP server,不再经过 proxy chain。 - nodeCmd := &cobra.Command{ + nodeCmd := newGroupCommand(&cobra.Command{ Use: "node", Short: "知识库节点管理", Long: `管理钉钉知识库中的节点(文档/文件夹/表格等):列出、创建、复制、移动、删除。`, RunE: groupRunE, - } + }) nodeListCmd := &cobra.Command{ Use: "list", @@ -1415,12 +1415,12 @@ ORG 类型授权不会出现在查询结果中。`, root.AddCommand(nodeCmd) // ── feed (知识库动态查询) ───────────────────────────────── - feedCmd := &cobra.Command{ + feedCmd := newGroupCommand(&cobra.Command{ Use: "feed", Short: "知识库动态查询", Long: `查询知识库的动态:谁在什么时间更新/上传/评论了哪些文档。`, RunE: groupRunE, - } + }) feedListCmd := &cobra.Command{ Use: "list", @@ -1536,14 +1536,14 @@ ORG 类型授权不会出现在查询结果中。`, ) // ── [PROXY] wiki file/doc * → doc * (兼容旧路径) ── - fileGroup := &cobra.Command{Use: "file", Hidden: true, RunE: groupRunE} + fileGroup := newGroupCommand(&cobra.Command{Use: "file", Hidden: true, RunE: groupRunE}) fileGroup.AddCommand( proxySubCmd("list", "doc", "list", nil), // [PROXY] wiki file list → doc list proxySubCmd("search", "doc", "search", map[string]string{"workspace": "workspace-ids"}), // [PROXY] wiki file search → doc search ) root.AddCommand(fileGroup) - docGroup := &cobra.Command{Use: "doc", Hidden: true, RunE: groupRunE} + docGroup := newGroupCommand(&cobra.Command{Use: "doc", Hidden: true, RunE: groupRunE}) docGroup.AddCommand( proxySubCmd("list", "doc", "list", nil), // [PROXY] wiki doc list → doc list proxySubCmd("read", "doc", "read", nil), // [PROXY] wiki doc read → doc read diff --git a/internal/pat/pat.go b/internal/pat/pat.go index f3684019..4d32cf88 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -20,6 +20,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -87,8 +88,9 @@ DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用): 它不参与 claw-type 派生,也不参与 host-owned PAT 判定。 DWS_CHANNEL 只用于上游 channelCode。`, - RunE: cmdutil.GroupRunE, + RunE: pipeline.GroupRunE, } + cmdutil.MarkGroup(patCmd) patCmd.AddCommand(newChmodCommand(c)) patCmd.AddCommand(newBrowserPolicyCommand()) diff --git a/internal/pipeline/command_resolution.go b/internal/pipeline/command_resolution.go index 6f15137d..1378e78f 100644 --- a/internal/pipeline/command_resolution.go +++ b/internal/pipeline/command_resolution.go @@ -5,7 +5,6 @@ package pipeline import ( "fmt" - "sort" "strings" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -13,14 +12,13 @@ import ( "github.com/spf13/cobra" ) -const maxCommandSuggestions = 3 - // validateUnresolvedCommand gives command identity precedence over flag // parsing. Cobra otherwise falls back to the nearest parent when a child is // unknown, so a later flag can incorrectly turn an invented command into an // "unknown flag" error on that parent. // -// Only two unambiguous command containers are handled here: +// Only explicit, unambiguous command containers are handled here: +// - the root command, which has no positional contract; // - a top-level service followed by an explicit +shortcut token; and // - a command explicitly annotated as a group container. // @@ -34,11 +32,17 @@ func validateUnresolvedCommand(target *cobra.Command, remaining []string) error if candidate == "" || candidate == "--" || strings.HasPrefix(candidate, "-") { return nil } + // Cobra registers its built-in `help [command]` lazily during ExecuteC, + // after this pre-parse traversal runs. Preserve that reserved root entry so + // `dws help auth` reaches Cobra instead of being classified as a typo. + if target == target.Root() && candidate == "help" { + return nil + } if isExplicitShortcutCandidate(target, candidate) { return unknownShortcutError(target, candidate) } - if cmdutil.IsGroup(target) { + if target == target.Root() || cmdutil.IsGroup(target) { return unknownSubcommandError(target, candidate) } return nil @@ -61,83 +65,70 @@ func isExplicitShortcutCandidate(target *cobra.Command, candidate string) bool { } func unknownShortcutError(parent *cobra.Command, candidate string) error { - action := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name()) + helpAction := fmt.Sprintf("Run '%s --help' for the full list", parent.CommandPath()) + listAction := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name()) + suggestions := cmdutil.SuggestSubcommands(parent, candidate) return apperrors.NewValidation( fmt.Sprintf("unknown shortcut %q for %q", candidate, parent.CommandPath()), apperrors.WithReason("unknown_shortcut"), - apperrors.WithHint(commandSuggestionHint(parent, candidate, action)), - apperrors.WithActions(action), + apperrors.WithHint(cmdutil.FormatSubcommandSuggestionHint(parent, suggestions, helpAction)), + apperrors.WithActions(helpAction, listAction), + apperrors.WithDetails(commandSuggestionDetails(candidate, suggestions)), ) } func unknownSubcommandError(parent *cobra.Command, candidate string) error { - action := fmt.Sprintf("Run '%s --help' to see available subcommands", parent.CommandPath()) + action := fmt.Sprintf("Run '%s --help' for the full list", parent.CommandPath()) + suggestions := cmdutil.SuggestSubcommands(parent, candidate) return apperrors.NewValidation( fmt.Sprintf("unknown subcommand %q for %q", candidate, parent.CommandPath()), apperrors.WithReason("unknown_subcommand"), - apperrors.WithHint(commandSuggestionHint(parent, candidate, action)), + apperrors.WithHint(cmdutil.FormatSubcommandSuggestionHint(parent, suggestions, action)), apperrors.WithActions(action), + apperrors.WithDetails(commandSuggestionDetails(candidate, suggestions)), ) } -func commandSuggestionHint(parent *cobra.Command, candidate, fallback string) string { - suggestions := commandSuggestions(parent, candidate) - if len(suggestions) > maxCommandSuggestions { - suggestions = suggestions[:maxCommandSuggestions] - } - paths := make([]string, 0, len(suggestions)) - for _, suggestion := range suggestions { - paths = append(paths, fmt.Sprintf("%q", parent.CommandPath()+" "+suggestion)) - } - switch len(paths) { - case 0: - return fallback - case 1: - return "Did you mean " + paths[0] + "?" - default: - return "Did you mean one of: " + strings.Join(paths, ", ") + "?" +func commandSuggestionDetails(candidate string, suggestions []string) map[string]any { + return map[string]any{ + "input": candidate, + "suggestions": suggestions, } } -// commandSuggestions mirrors Cobra's suggestion rules without mutating -// SuggestionsMinimumDistance. Cobra only installs its default distance inside -// the final error renderer; this guard runs earlier and must remain safe for -// command trees shared by concurrent tests and callers. -func commandSuggestions(parent *cobra.Command, candidate string) []string { - if parent == nil { - return nil +// HintSubCmd creates a hidden compatibility command for a known wrong path. +// It preserves cmdutil's hint-only identity while returning the same typed +// validation contract as fuzzy unknown-subcommand recovery. +func HintSubCmd(use, authoredHint string) *cobra.Command { + command := cmdutil.HintSubCmd(use, authoredHint) + command.RunE = func(cmd *cobra.Command, _ []string) error { + parent := cmd.Parent() + if parent == nil { + parent = cmd.Root() + } + parentPath := parent.CommandPath() + action := fmt.Sprintf("Run '%s --help' for the full list", parentPath) + hint := strings.TrimSpace(authoredHint) + if hint == "" { + hint = action + } else { + hint += " (" + action + ")" + } + return apperrors.NewValidation( + fmt.Sprintf("unknown subcommand %q for %q", cmd.Name(), parentPath), + apperrors.WithReason("unknown_subcommand"), + apperrors.WithHint(hint), + apperrors.WithActions(action), + apperrors.WithDetails(commandSuggestionDetails(cmd.Name(), []string{})), + ) } - minimumDistance := parent.SuggestionsMinimumDistance - if minimumDistance <= 0 { - minimumDistance = 2 + return command +} + +// GroupRunE is the structured group handler used by the DWS command tree. +func GroupRunE(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return unknownSubcommandError(cmd, strings.TrimSpace(args[0])) } - lowerCandidate := strings.ToLower(candidate) - shortcutOnly := strings.HasPrefix(candidate, "+") - seen := make(map[string]bool) - var suggestions []string - for _, child := range parent.Commands() { - if !child.IsAvailableCommand() { - continue - } - name := child.Name() - if shortcutOnly && !strings.HasPrefix(name, "+") { - continue - } - matched := cmdutil.LevenshteinDist(lowerCandidate, strings.ToLower(name)) <= minimumDistance || - strings.HasPrefix(strings.ToLower(name), lowerCandidate) - if !matched { - for _, explicit := range child.SuggestFor { - if strings.EqualFold(candidate, explicit) { - matched = true - break - } - } - } - if matched && !seen[name] { - seen[name] = true - suggestions = append(suggestions, name) - } - } - sort.Strings(suggestions) - return suggestions + return cmd.Help() } diff --git a/internal/pipeline/command_resolution_test.go b/internal/pipeline/command_resolution_test.go index 31d7bff4..5fecb7eb 100644 --- a/internal/pipeline/command_resolution_test.go +++ b/internal/pipeline/command_resolution_test.go @@ -28,10 +28,11 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeF if structured.Message != `unknown shortcut "+chat-mesages" for "dws chat"` { t.Fatalf("Message = %q", structured.Message) } - if structured.Hint != `Did you mean "dws chat +chat-messages"?` { + if structured.Hint != `Did you mean "dws chat +chat-messages"? (Run 'dws chat --help' for the full list)` { t.Fatalf("Hint = %q", structured.Hint) } - if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws shortcut list --service chat --format json'" { + if len(structured.Actions) != 2 || structured.Actions[0] != "Run 'dws chat --help' for the full list" || + structured.Actions[1] != "Run 'dws shortcut list --service chat --format json'" { t.Fatalf("Actions = %#v", structured.Actions) } if len(structured.AvailableFlags) != 0 { @@ -53,21 +54,31 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitCon if structured.Message != `unknown subcommand "search" for "dws dev app"` { t.Fatalf("Message = %q", structured.Message) } - if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' to see available subcommands" { + if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' for the full list" { t.Fatalf("Actions = %#v", structured.Actions) } + legacy := &cobra.Command{Use: "legacy", RunE: GroupRunE} + cmdutil.MarkGroup(legacy) + legacy.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(legacy) + requireCommandResolutionError(t, validateUnresolvedCommand(legacy, []string{"lisst", "--query", "x"}), "unknown_subcommand") positional := &cobra.Command{Use: "schema [path]", Run: func(*cobra.Command, []string) {}} root.AddCommand(positional) + positionalWithChild := &cobra.Command{Use: "query [term]", Args: cobra.ExactArgs(1), Run: func(*cobra.Command, []string) {}} + positionalWithChild.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(positionalWithChild) for name, test := range map[string]struct { target *cobra.Command remaining []string }{ - "legitimate positional": {target: positional, remaining: []string{"+chat-messages"}}, - "flag remains a flag": {target: app, remaining: []string{"--keyword", "x"}}, - "dash terminator": {target: app, remaining: []string{"--", "search"}}, - "nil target": {target: nil, remaining: []string{"search"}}, - "empty remaining": {target: app}, + "cobra help command": {target: root, remaining: []string{"help", "chat"}}, + "legitimate positional": {target: positional, remaining: []string{"+chat-messages"}}, + "positional close to child command": {target: positionalWithChild, remaining: []string{"lis"}}, + "flag remains a flag": {target: app, remaining: []string{"--keyword", "x"}}, + "dash terminator": {target: app, remaining: []string{"--", "search"}}, + "nil target": {target: nil, remaining: []string{"search"}}, + "empty remaining": {target: app}, } { t.Run(name, func(t *testing.T) { if err := validateUnresolvedCommand(test.target, test.remaining); err != nil { @@ -108,17 +119,17 @@ func TestCrossPlatformCoverageCommandSuggestionHintIsBoundedAndFallsBack(t *test root := &cobra.Command{Use: "dws"} group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100} root.AddCommand(group) - for _, name := range []string{"alpha", "beta", "delta", "gamma"} { + for _, name := range []string{"aaa", "alpha", "alphi", "alphx"} { group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}}) } - hint := commandSuggestionHint(group, "missing", "fallback") - if strings.Count(hint, `"dws demo `) != maxCommandSuggestions || strings.Contains(hint, "gamma") { - t.Fatalf("bounded hint = %q", hint) + hint := cmdutil.FormatSubcommandSuggestionHint(group, cmdutil.SuggestSubcommands(group, "alpho"), "fallback") + if strings.Count(hint, `"dws demo `) != cmdutil.MaxCommandSuggestions || strings.Contains(hint, `"dws demo aaa"`) || !strings.Contains(hint, `"dws demo alphx"`) { + t.Fatalf("nearest bounded hint = %q", hint) } empty := &cobra.Command{Use: "empty"} root.AddCommand(empty) - if got := commandSuggestionHint(empty, "missing", "fallback"); got != "fallback" { + if got := cmdutil.FormatSubcommandSuggestionHint(empty, cmdutil.SuggestSubcommands(empty, "missing"), "fallback"); got != "fallback" { t.Fatalf("fallback hint = %q", got) } } @@ -136,8 +147,8 @@ func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPat if isExplicitShortcutCandidate(group, "+missing") { t.Fatal("nested command was treated as a top-level shortcut service") } - if got := commandSuggestions(nil, "missing"); got != nil { - t.Fatalf("commandSuggestions(nil) = %#v", got) + if got := cmdutil.SuggestSubcommands(nil, "missing"); got != nil { + t.Fatalf("SuggestSubcommands(nil) = %#v", got) } service.SuggestionsMinimumDistance = 1 @@ -146,11 +157,34 @@ func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPat SuggestFor: []string{"invented"}, Run: func(*cobra.Command, []string) {}, }) - if got := commandSuggestions(service, "invented"); len(got) != 1 || got[0] != "canonical" { + if got := cmdutil.SuggestSubcommands(service, "invented"); len(got) != 1 || got[0] != "canonical" { t.Fatalf("explicit SuggestFor suggestions = %#v", got) } } +func TestCrossPlatformCoverageHintSubCmdReturnsTypedRecovery(t *testing.T) { + root := &cobra.Command{Use: "dws"} + parent := &cobra.Command{Use: "chat"} + hint := HintSubCmd("send", "use: dws chat message send") + root.AddCommand(parent) + parent.AddCommand(hint) + + err := hint.RunE(hint, nil) + var structured *apperrors.Error + if !stderrors.As(err, &structured) { + t.Fatalf("HintSubCmd error = %T %v", err, err) + } + if structured.Category != apperrors.CategoryValidation || structured.Reason != "unknown_subcommand" { + t.Fatalf("HintSubCmd error = %#v", structured) + } + if !strings.Contains(structured.Hint, "dws chat message send") || !strings.Contains(structured.Hint, "dws chat --help") { + t.Fatalf("HintSubCmd hint = %q", structured.Hint) + } + if !cmdutil.IsHintOnlyCommand(hint) { + t.Fatal("HintSubCmd lost hint-only identity") + } +} + func requireCommandResolutionError(t *testing.T, err error, reason string) *apperrors.Error { t.Helper() var structured *apperrors.Error diff --git a/internal/shortcut/register.go b/internal/shortcut/register.go index cab1c429..6073ff5d 100644 --- a/internal/shortcut/register.go +++ b/internal/shortcut/register.go @@ -16,6 +16,7 @@ package shortcut import ( "sort" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -72,6 +73,7 @@ func build(shortcuts []Shortcut) []*cobra.Command { Use: s.Service, Short: s.Service + " shortcuts", } + cmdutil.MarkGroup(parent) byService[s.Service] = parent order = append(order, s.Service) } diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index 5cbf5704..9fcb7e3d 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -24,6 +24,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -472,6 +473,11 @@ func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) { if got := len(byName["calendar"].Commands()); got != 1 { t.Errorf("calendar has %d leaves, want 1", got) } + for name, command := range byName { + if !cmdutil.IsGroup(command) { + t.Errorf("%s shortcut service parent is not marked as a command group", name) + } + } } func TestBuiltInCommandsExcludeUserDefinedShortcuts(t *testing.T) { diff --git a/pkg/cmdutil/command_suggestions.go b/pkg/cmdutil/command_suggestions.go new file mode 100644 index 00000000..e2491c5d --- /dev/null +++ b/pkg/cmdutil/command_suggestions.go @@ -0,0 +1,142 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cmdutil + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// MaxCommandSuggestions keeps typo recovery concise even for products with a +// large command surface. +const MaxCommandSuggestions = 3 + +type rankedCommandSuggestion struct { + name string + explicit bool + prefix bool + distance int + lengthDelta int +} + +// SuggestSubcommands returns at most MaxCommandSuggestions visible canonical +// child names, ranked by reviewed SuggestFor matches, prefix relevance, edit +// distance, length delta, and finally canonical name for deterministic ties. +// Aliases participate in scoring, but recovery always teaches the canonical +// command name. +func SuggestSubcommands(parent *cobra.Command, candidate string) []string { + if parent == nil { + return nil + } + candidate = strings.ToLower(strings.TrimSpace(candidate)) + if candidate == "" { + return []string{} + } + minimumDistance := parent.SuggestionsMinimumDistance + if minimumDistance <= 0 { + minimumDistance = 2 + } + shortcutOnly := strings.HasPrefix(candidate, "+") + ranked := make([]rankedCommandSuggestion, 0) + for _, child := range parent.Commands() { + if !child.IsAvailableCommand() || (shortcutOnly && !strings.HasPrefix(child.Name(), "+")) { + continue + } + + explicit := false + for _, suggestion := range child.SuggestFor { + if strings.EqualFold(candidate, suggestion) { + explicit = true + break + } + } + + identities := append([]string{child.Name()}, child.Aliases...) + prefix := false + bestDistance := -1 + bestLengthDelta := 0 + for _, identity := range identities { + identity = strings.ToLower(strings.TrimSpace(identity)) + if identity == "" { + continue + } + if strings.HasPrefix(identity, candidate) { + prefix = true + } + distance := LevenshteinDist(candidate, identity) + lengthDelta := len(identity) - len(candidate) + if lengthDelta < 0 { + lengthDelta = -lengthDelta + } + if bestDistance < 0 || distance < bestDistance || (distance == bestDistance && lengthDelta < bestLengthDelta) { + bestDistance = distance + bestLengthDelta = lengthDelta + } + } + if !explicit && !prefix && (bestDistance < 0 || bestDistance > minimumDistance) { + continue + } + ranked = append(ranked, rankedCommandSuggestion{ + name: child.Name(), + explicit: explicit, + prefix: prefix, + distance: bestDistance, + lengthDelta: bestLengthDelta, + }) + } + + sort.Slice(ranked, func(i, j int) bool { + left, right := ranked[i], ranked[j] + if left.explicit != right.explicit { + return left.explicit + } + if left.prefix != right.prefix { + return left.prefix + } + if left.distance != right.distance { + return left.distance < right.distance + } + if left.lengthDelta != right.lengthDelta { + return left.lengthDelta < right.lengthDelta + } + return left.name < right.name + }) + if len(ranked) > MaxCommandSuggestions { + ranked = ranked[:MaxCommandSuggestions] + } + + suggestions := make([]string, 0, len(ranked)) + for _, suggestion := range ranked { + suggestions = append(suggestions, suggestion.name) + } + return suggestions +} + +// FormatSubcommandSuggestionHint renders bounded suggestions and always keeps +// the full-list recovery action visible in the same hint. +func FormatSubcommandSuggestionHint(parent *cobra.Command, suggestions []string, fallback string) string { + if len(suggestions) > MaxCommandSuggestions { + suggestions = suggestions[:MaxCommandSuggestions] + } + paths := make([]string, 0, len(suggestions)) + for _, suggestion := range suggestions { + paths = append(paths, fmt.Sprintf("%q", parent.CommandPath()+" "+suggestion)) + } + var hint string + switch len(paths) { + case 0: + return fallback + case 1: + hint = "Did you mean " + paths[0] + "?" + default: + hint = "Did you mean one of: " + strings.Join(paths, ", ") + "?" + } + if fallback != "" { + hint += " (" + fallback + ")" + } + return hint +} diff --git a/pkg/cmdutil/command_suggestions_test.go b/pkg/cmdutil/command_suggestions_test.go new file mode 100644 index 00000000..af96add3 --- /dev/null +++ b/pkg/cmdutil/command_suggestions_test.go @@ -0,0 +1,68 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cmdutil + +import ( + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageSuggestSubcommandsRanksNearestAndBoundsResults(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100} + root.AddCommand(group) + for _, name := range []string{"aaa", "alpha", "alphi", "alphx"} { + group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}}) + } + group.AddCommand(&cobra.Command{Use: "alpho-hidden", Hidden: true, Run: func(*cobra.Command, []string) {}}) + + want := []string{"alpha", "alphi", "alphx"} + if got := SuggestSubcommands(group, "alpho"); !slices.Equal(got, want) { + t.Fatalf("SuggestSubcommands() = %#v, want nearest %#v", got, want) + } +} + +func TestCrossPlatformCoverageSuggestSubcommandsUsesAliasesAndReviewedSuggestions(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 1} + root.AddCommand(group) + group.AddCommand( + &cobra.Command{Use: "canonical", Aliases: []string{"metas"}, Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "reviewed", SuggestFor: []string{"legacy-command"}, Run: func(*cobra.Command, []string) {}}, + ) + + if got := SuggestSubcommands(group, "meta"); !slices.Equal(got, []string{"canonical"}) { + t.Fatalf("alias suggestion = %#v, want canonical command", got) + } + if got := SuggestSubcommands(group, "legacy-command"); !slices.Equal(got, []string{"reviewed"}) { + t.Fatalf("reviewed SuggestFor = %#v", got) + } +} + +func TestCrossPlatformCoverageGroupRunERemainsConciseWithoutSuggestions(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo"} + root.AddCommand(group) + for _, name := range []string{"alpha", "beta", "gamma", "delta"} { + group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}}) + } + + err := GroupRunE(group, []string{"zzzzz"}) + if err == nil || strings.Contains(err.Error(), "available:") || !strings.Contains(err.Error(), "dws demo --help") { + t.Fatalf("GroupRunE() error = %v, want concise parent-help guidance", err) + } +} + +func TestCrossPlatformCoverageFormatSubcommandSuggestionHintDefensivelyBoundsInput(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo"} + root.AddCommand(group) + hint := FormatSubcommandSuggestionHint(group, []string{"one", "two", "three", "four"}, "fallback") + if strings.Count(hint, `"dws demo `) != MaxCommandSuggestions || strings.Contains(hint, "four") { + t.Fatalf("unbounded formatted hint = %q", hint) + } +} diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go index 07b9fbc5..d35bbc6b 100644 --- a/pkg/cmdutil/flags.go +++ b/pkg/cmdutil/flags.go @@ -28,18 +28,14 @@ import ( const hintOnlyCommandAnnotation = "dws.command.hint_only" // GroupRunE is a reusable RunE for parent (group) commands that have no -// business logic of their own. With args it returns an error listing available -// subcommands; without args it shows help. +// business logic of their own. With args it returns bounded typo guidance; +// without args it shows help. DWS production trees use pipeline.GroupRunE so +// the same diagnostic is also projected as a structured validation error. func GroupRunE(cmd *cobra.Command, args []string) error { if len(args) > 0 { - var names []string - for _, sub := range cmd.Commands() { - if !sub.Hidden { - names = append(names, sub.Name()) - } - } - return fmt.Errorf("unknown subcommand %q for %q\n available: %s\n hint: %s --help", - args[0], cmd.CommandPath(), strings.Join(names, ", "), cmd.CommandPath()) + fallback := fmt.Sprintf("Run '%s --help' for the full list", cmd.CommandPath()) + return fmt.Errorf("unknown subcommand %q for %q\n hint: %s", + args[0], cmd.CommandPath(), FormatSubcommandSuggestionHint(cmd, SuggestSubcommands(cmd, args[0]), fallback)) } return cmd.Help() } diff --git a/test/smoke/cli_smoke_test.go b/test/smoke/cli_smoke_test.go index fbed6e4e..455ca777 100644 --- a/test/smoke/cli_smoke_test.go +++ b/test/smoke/cli_smoke_test.go @@ -4,10 +4,12 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "os" "os/exec" "path/filepath" + "slices" "sort" "strings" "testing" @@ -132,6 +134,242 @@ func TestCLISmoke_RepresentativeStaticCommandsReturnMockJSON(t *testing.T) { } } +func TestCrossPlatformCoverageCLISmokeCommandTypoGuidance(t *testing.T) { + env := isolatedCLIEnv(t) + tests := []struct { + name string + args []string + wantInput string + wantHelp string + wantHint string + wantSuggestion string + wantReason string + }{ + { + name: "legacy group typo", + args: []string{"chat", "searchh", "--query", "ignored", "--format", "json"}, + wantInput: "searchh", + wantHelp: "dws chat --help", + wantSuggestion: "search", + }, + { + name: "nested legacy group typo with child flag", + args: []string{"chat", "message", "liss", "--conversation-id", "cid", "--format", "json"}, + wantInput: "liss", + wantHelp: "dws chat message --help", + wantSuggestion: "list", + }, + { + name: "legacy group typo without suggestion", + args: []string{"chat", "zzzzz", "--format", "json"}, + wantInput: "zzzzz", + wantHelp: "dws chat --help", + }, + { + name: "top level typo", + args: []string{"chatt", "--format", "json"}, + wantInput: "chatt", + wantHelp: "dws --help", + wantSuggestion: "chat", + }, + { + name: "auth group typo", + args: []string{"auth", "sttus", "--format", "json"}, + wantInput: "sttus", + wantHelp: "dws auth --help", + wantSuggestion: "status", + }, + { + name: "event group typo", + args: []string{"event", "lisst", "--format", "json"}, + wantInput: "lisst", + wantHelp: "dws event --help", + wantSuggestion: "list", + }, + { + name: "shortcut group typo", + args: []string{"shortcut", "lst", "--format", "json"}, + wantInput: "lst", + wantHelp: "dws shortcut --help", + wantSuggestion: "list", + }, + { + name: "plus shortcut typo", + args: []string{"chat", "+chat-mesages", "--format", "json"}, + wantInput: "+chat-mesages", + wantHelp: "dws chat --help", + wantSuggestion: "+chat-messages", + wantReason: "unknown_shortcut", + }, + { + name: "audit group typo", + args: []string{"audit", "tal", "--format", "json"}, + wantInput: "tal", + wantHelp: "dws audit --help", + wantSuggestion: "tail", + }, + { + name: "hybrid aisearch typo", + args: []string{"aisearch", "persno", "--query", "ignored", "--format", "json"}, + wantInput: "persno", + wantHelp: "dws aisearch --help", + wantSuggestion: "person", + }, + { + name: "hybrid dev connect typo", + args: []string{"dev", "connect", "statsu", "--format", "json"}, + wantInput: "statsu", + wantHelp: "dws dev connect --help", + wantSuggestion: "status", + }, + { + name: "hybrid aitable view typo", + args: []string{"aitable", "view", "get", "crad", "--format", "json"}, + wantInput: "crad", + wantHelp: "dws aitable view get --help", + wantSuggestion: "card", + }, + { + name: "hybrid chat members typo", + args: []string{"chat", "group", "members", "ad", "--format", "json"}, + wantInput: "ad", + wantHelp: "dws chat group members --help", + wantSuggestion: "add", + }, + { + name: "hybrid doc export typo", + args: []string{"doc", "export", "gte", "--format", "json"}, + wantInput: "gte", + wantHelp: "dws doc export --help", + wantSuggestion: "get", + }, + { + name: "hybrid report inbox typo", + args: []string{"report", "inbox", "lsit", "--format", "json"}, + wantInput: "lsit", + wantHelp: "dws report inbox --help", + wantSuggestion: "list", + }, + { + name: "nested app group typo", + args: []string{"mcp", "url", "gt", "--format", "json"}, + wantInput: "gt", + wantHelp: "dws mcp url --help", + wantSuggestion: "get", + }, + { + name: "calendar group typo", + args: []string{"calendar", "room", "serach", "--format", "json"}, + wantInput: "serach", + wantHelp: "dws calendar room --help", + wantSuggestion: "search", + }, + { + name: "sheet group typo", + args: []string{"sheet", "range", "reead", "--format", "json"}, + wantInput: "reead", + wantHelp: "dws sheet range --help", + wantSuggestion: "read", + }, + { + name: "shared compatibility hint", + args: []string{"todo", "create", "--format", "json"}, + wantInput: "create", + wantHelp: "dws todo --help", + wantHint: "dws todo task create", + }, + { + name: "chat compatibility hint", + args: []string{"chat", "send", "--group", "cid", "--text", "hello", "--format", "json"}, + wantInput: "send", + wantHelp: "dws chat --help", + wantHint: "dws chat message send", + }, + { + name: "contact compatibility hint", + args: []string{"contact", "user", "info", "--ids", "uid", "--format", "json"}, + wantInput: "info", + wantHelp: "dws contact user --help", + wantHint: "dws contact user get", + }, + { + name: "calendar compatibility hint", + args: []string{"calendar", "get", "--format", "json"}, + wantInput: "get", + wantHelp: "dws calendar --help", + wantHint: "dws calendar event get", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr, err := runCLI(t, env, test.args...) + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) || exitErr.ExitCode() != 3 { + t.Fatalf("dws %s exit = %v, want 3\nstdout:\n%s\nstderr:\n%s", strings.Join(test.args, " "), err, stdout, stderr) + } + if strings.TrimSpace(stdout) != "" { + t.Fatalf("dws %s wrote stdout:\n%s", strings.Join(test.args, " "), stdout) + } + if strings.Contains(stderr, "available:") { + t.Fatalf("dws %s leaked an unbounded command list:\n%s", strings.Join(test.args, " "), stderr) + } + + var payload struct { + Error struct { + Category string `json:"category"` + Reason string `json:"reason"` + Hint string `json:"hint"` + Details struct { + Input string `json:"input"` + Suggestions []string `json:"suggestions"` + } `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(stderr), &payload); err != nil { + t.Fatalf("dws %s returned non-JSON stderr: %v\nstderr:\n%s", strings.Join(test.args, " "), err, stderr) + } + wantReason := test.wantReason + if wantReason == "" { + wantReason = "unknown_subcommand" + } + if payload.Error.Category != "validation" || payload.Error.Reason != wantReason { + t.Fatalf("dws %s error = %#v", strings.Join(test.args, " "), payload.Error) + } + if !strings.Contains(payload.Error.Hint, test.wantHelp) { + t.Fatalf("dws %s hint = %q, want parent help %q", strings.Join(test.args, " "), payload.Error.Hint, test.wantHelp) + } + if test.wantHint != "" && !strings.Contains(payload.Error.Hint, test.wantHint) { + t.Fatalf("dws %s hint = %q, want authored guidance %q", strings.Join(test.args, " "), payload.Error.Hint, test.wantHint) + } + if payload.Error.Details.Input != test.wantInput { + t.Fatalf("dws %s details.input = %q, want %q", strings.Join(test.args, " "), payload.Error.Details.Input, test.wantInput) + } + if len(payload.Error.Details.Suggestions) > 3 { + t.Fatalf("dws %s suggestions = %#v, want at most 3", strings.Join(test.args, " "), payload.Error.Details.Suggestions) + } + if test.wantSuggestion != "" && !slices.Contains(payload.Error.Details.Suggestions, test.wantSuggestion) { + t.Fatalf("dws %s suggestions = %#v, want %q", strings.Join(test.args, " "), payload.Error.Details.Suggestions, test.wantSuggestion) + } + }) + } + + t.Run("legitimate positional commands remain executable", func(t *testing.T) { + stdout, stderr, err := runCLI(t, env, "schema", "chat message send", "--compact", "--format", "json") + if err != nil || strings.TrimSpace(stdout) == "" { + t.Fatalf("dws schema positional path failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + stdout, stderr, err = runCLI(t, env, "completion", "zsh") + if err != nil || strings.TrimSpace(stdout) == "" { + t.Fatalf("dws completion positional shell failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + stdout, stderr, err = runCLI(t, env, "help", "auth") + if err != nil || !strings.Contains(stdout, "dws auth") { + t.Fatalf("dws help auth failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) + } + }) +} + // publicCommandPaths follows only canonical, user-visible Cobra commands. // Hidden commands and commands marked Deprecated by Cobra are intentionally // excluded; aliases are not separate command nodes. Parents and leaves are both From f5378d2f281ebfffe07f3534262328c23775bea6 Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 24 Aug 2026 15:27:27 +0800 Subject: [PATCH 25/33] test: cover typo guidance CI edges --- .../helpers/small_remaining_coverage_test.go | 17 ++++++ internal/pipeline/command_resolution_test.go | 30 +++++++++ pkg/cmdutil/command_suggestions_test.go | 61 +++++++++++++++++++ test/smoke/cli_smoke_test.go | 13 +++- 4 files changed, 118 insertions(+), 3 deletions(-) diff --git a/internal/helpers/small_remaining_coverage_test.go b/internal/helpers/small_remaining_coverage_test.go index 3f73cd70..d3710af0 100644 --- a/internal/helpers/small_remaining_coverage_test.go +++ b/internal/helpers/small_remaining_coverage_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "slices" "strings" "testing" "time" @@ -28,6 +29,22 @@ func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T t.Fatalf("deep suggestion err=%#v", err) } + bounded := &cobra.Command{Use: "sheet"} + for _, name := range []string{"alpha", "beta", "delta", "gamma"} { + group := &cobra.Command{Use: name} + group.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) + bounded.AddCommand(group) + } + attachUnknownSubcommandGuard(bounded) + err = bounded.RunE(bounded, []string{"read"}) + if !errors.As(err, &structured) { + t.Fatalf("bounded deep suggestion error = %T %v", err, err) + } + suggestions, ok := structured.Details["suggestions"].([]string) + if !ok || !slices.Equal(suggestions, []string{"alpha read", "beta read", "delta read"}) { + t.Fatalf("bounded deep suggestions = %#v", structured.Details["suggestions"]) + } + installScriptedCaller(t, &scriptedToolCaller{dry: true}) if err := executeFilterCoverage(t, newMinutesCommand(), "permission", "add", "--ids", "id", "--member-uids", "uid", "--policy", "3", "--cover=false"); err != nil { t.Fatalf("explicit false cover: %v", err) diff --git a/internal/pipeline/command_resolution_test.go b/internal/pipeline/command_resolution_test.go index 5fecb7eb..a8b1fbb7 100644 --- a/internal/pipeline/command_resolution_test.go +++ b/internal/pipeline/command_resolution_test.go @@ -185,6 +185,36 @@ func TestCrossPlatformCoverageHintSubCmdReturnsTypedRecovery(t *testing.T) { } } +func TestCrossPlatformCoverageHintSubCmdAndGroupRunEDefensiveBranches(t *testing.T) { + standaloneHint := HintSubCmd("send", " \t ") + structured := requireCommandResolutionError(t, standaloneHint.RunE(standaloneHint, nil), "unknown_subcommand") + if structured.Hint != "Run 'send --help' for the full list" { + t.Fatalf("standalone empty hint = %q", structured.Hint) + } + + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo"} + group.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(group) + structured = requireCommandResolutionError(t, GroupRunE(group, []string{"lisst"}), "unknown_subcommand") + if structured.Details["input"] != "lisst" { + t.Fatalf("GroupRunE() details.input = %#v", structured.Details["input"]) + } + suggestions, ok := structured.Details["suggestions"].([]string) + if !ok || len(suggestions) != 1 || suggestions[0] != "list" { + t.Fatalf("GroupRunE() details.suggestions = %#v", structured.Details["suggestions"]) + } + + var help strings.Builder + group.SetOut(&help) + if err := GroupRunE(group, nil); err != nil { + t.Fatalf("GroupRunE() help error = %v", err) + } + if output := help.String(); !strings.Contains(output, "Usage:") || !strings.Contains(output, "list") { + t.Fatalf("GroupRunE() help output = %q", output) + } +} + func requireCommandResolutionError(t *testing.T, err error, reason string) *apperrors.Error { t.Helper() var structured *apperrors.Error diff --git a/pkg/cmdutil/command_suggestions_test.go b/pkg/cmdutil/command_suggestions_test.go index af96add3..9b0d2a1e 100644 --- a/pkg/cmdutil/command_suggestions_test.go +++ b/pkg/cmdutil/command_suggestions_test.go @@ -65,4 +65,65 @@ func TestCrossPlatformCoverageFormatSubcommandSuggestionHintDefensivelyBoundsInp if strings.Count(hint, `"dws demo `) != MaxCommandSuggestions || strings.Contains(hint, "four") { t.Fatalf("unbounded formatted hint = %q", hint) } + if hint := FormatSubcommandSuggestionHint(group, []string{"one"}, "fallback"); hint != `Did you mean "dws demo one"? (fallback)` { + t.Fatalf("single suggestion hint = %q", hint) + } +} + +func TestCrossPlatformCoverageSuggestSubcommandsDefensiveInputs(t *testing.T) { + if got := SuggestSubcommands(nil, "candidate"); got != nil { + t.Fatalf("SuggestSubcommands(nil) = %#v, want nil", got) + } + + group := &cobra.Command{Use: "demo"} + group.AddCommand(&cobra.Command{ + Use: "target", + Aliases: []string{""}, + Run: func(*cobra.Command, []string) {}, + }) + if got := SuggestSubcommands(group, " \t "); len(got) != 0 { + t.Fatalf("empty candidate suggestions = %#v", got) + } + if got := SuggestSubcommands(group, "target"); !slices.Equal(got, []string{"target"}) { + t.Fatalf("empty alias changed canonical suggestion = %#v", got) + } + + blankGroup := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100} + blankGroup.AddCommand(&cobra.Command{Use: "", Run: func(*cobra.Command, []string) {}}) + if got := SuggestSubcommands(blankGroup, "candidate"); len(got) != 0 { + t.Fatalf("blank command identity suggestions = %#v", got) + } +} + +func TestCrossPlatformCoverageSuggestSubcommandsRankingTieBreakers(t *testing.T) { + newGroup := func() *cobra.Command { + return &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100} + } + + explicit := newGroup() + explicit.AddCommand( + &cobra.Command{Use: "reviewed", SuggestFor: []string{"legacy"}, Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "ordinary", Run: func(*cobra.Command, []string) {}}, + ) + if got := SuggestSubcommands(explicit, "legacy"); len(got) < 2 || got[0] != "reviewed" { + t.Fatalf("explicit ranking = %#v", got) + } + + prefix := newGroup() + prefix.AddCommand( + &cobra.Command{Use: "alpha", Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "xlp", Run: func(*cobra.Command, []string) {}}, + ) + if got := SuggestSubcommands(prefix, "alp"); len(got) < 2 || got[0] != "alpha" { + t.Fatalf("prefix ranking = %#v", got) + } + + lengthDelta := newGroup() + lengthDelta.AddCommand( + &cobra.Command{Use: "ab", Run: func(*cobra.Command, []string) {}}, + &cobra.Command{Use: "axc", Run: func(*cobra.Command, []string) {}}, + ) + if got := SuggestSubcommands(lengthDelta, "abc"); !slices.Equal(got, []string{"axc", "ab"}) { + t.Fatalf("length-delta ranking = %#v", got) + } } diff --git a/test/smoke/cli_smoke_test.go b/test/smoke/cli_smoke_test.go index 455ca777..77de1123 100644 --- a/test/smoke/cli_smoke_test.go +++ b/test/smoke/cli_smoke_test.go @@ -355,7 +355,9 @@ func TestCrossPlatformCoverageCLISmokeCommandTypoGuidance(t *testing.T) { } t.Run("legitimate positional commands remain executable", func(t *testing.T) { - stdout, stderr, err := runCLI(t, env, "schema", "chat message send", "--compact", "--format", "json") + const positionalCommandTimeout = 30 * time.Second + + stdout, stderr, err := runCLIWithTimeout(t, env, positionalCommandTimeout, "schema", "chat message send", "--compact", "--format", "json") if err != nil || strings.TrimSpace(stdout) == "" { t.Fatalf("dws schema positional path failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) } @@ -363,7 +365,7 @@ func TestCrossPlatformCoverageCLISmokeCommandTypoGuidance(t *testing.T) { if err != nil || strings.TrimSpace(stdout) == "" { t.Fatalf("dws completion positional shell failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) } - stdout, stderr, err = runCLI(t, env, "help", "auth") + stdout, stderr, err = runCLIWithTimeout(t, env, positionalCommandTimeout, "help", "auth") if err != nil || !strings.Contains(stdout, "dws auth") { t.Fatalf("dws help auth failed: %v\nstdout:\n%s\nstderr:\n%s", err, stdout, stderr) } @@ -434,8 +436,13 @@ func isolatedCLIEnv(t *testing.T) []string { func runCLI(t *testing.T, env []string, args ...string) (string, string, error) { t.Helper() + return runCLIWithTimeout(t, env, 10*time.Second, args...) +} - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) +func runCLIWithTimeout(t *testing.T, env []string, timeout time.Duration, args ...string) (string, string, error) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() processArgs := append([]string{"-test.run=^TestCLIHelperProcess$", "--"}, args...) cmd := exec.CommandContext(ctx, os.Args[0], processArgs...) From 2a42e282c774eb6455fb4931a1de936285e6af45 Mon Sep 17 00:00:00 2001 From: chichuan Date: Mon, 24 Aug 2026 22:05:39 +0800 Subject: [PATCH 26/33] fix: align typo guidance with command framework --- AGENTS.md | 4 +- docs/rfc-command-framework-convergence.md | 21 +- internal/app/group_policy_tree_test.go | 55 ++++ internal/app/mcp_url_command.go | 8 +- internal/app/plugin_commands.go | 7 +- internal/app/plugin_commands_coverage_test.go | 3 +- internal/app/profile_command_test.go | 8 +- internal/app/root.go | 42 ++- internal/cobracmd/tree.go | 148 ++++++++- internal/cobracmd/tree_test.go | 292 +++++++++++++++++- internal/corecmd/corecmd.go | 14 +- internal/corecmd/group_policy.go | 218 +++++++++++++ internal/corecmd/group_policy_test.go | 286 +++++++++++++++++ internal/helpers/aisearch.go | 2 +- internal/helpers/aitable.go | 4 +- internal/helpers/calendar.go | 168 ---------- .../calendar_command_coverage_more_test.go | 16 +- .../helpers/calendar_coverage_more_test.go | 39 +-- internal/helpers/chat.go | 2 +- internal/helpers/chat/toolbar.go | 8 +- internal/helpers/chat_media_upload.go | 2 +- internal/helpers/college_contact.go | 16 +- internal/helpers/conference.go | 2 +- internal/helpers/coverage_pure_more_test.go | 30 +- internal/helpers/dev.go | 5 +- internal/helpers/devapp.go | 19 +- internal/helpers/devapp_connect.go | 2 +- internal/helpers/doc.go | 3 +- internal/helpers/edu_app.go | 20 +- internal/helpers/edu_contact.go | 12 +- internal/helpers/edu_family_group.go | 8 +- internal/helpers/edu_group.go | 10 +- .../helpers/group_command_declaration_test.go | 101 ------ internal/helpers/helpers.go | 45 ++- internal/helpers/interfaces.go | 5 - internal/helpers/report.go | 2 +- internal/helpers/sheet.go | 92 +----- internal/helpers/sheet_chart.go | 2 +- internal/helpers/sheet_cond_format.go | 2 +- internal/helpers/sheet_filter_view.go | 4 +- internal/helpers/sheet_import.go | 1 + internal/helpers/sheet_pivot_table.go | 3 +- internal/helpers/sheet_range_ops.go | 2 +- internal/helpers/sheet_template.go | 4 +- .../helpers/small_remaining_coverage_test.go | 19 +- internal/pat/pat.go | 10 +- internal/pat/pat_register_test.go | 3 +- internal/pipeline/cobra.go | 8 +- internal/pipeline/command_fallback_test.go | 16 + internal/pipeline/command_resolution.go | 119 ++----- internal/pipeline/command_resolution_test.go | 94 +++++- internal/shortcut/register.go | 8 +- internal/shortcut/shortcut_test.go | 6 +- pkg/cmdutil/command_resolution.go | 222 +++++++++++++ pkg/cmdutil/command_resolution_test.go | 146 +++++++++ pkg/cmdutil/command_suggestions.go | 46 ++- pkg/cmdutil/command_suggestions_test.go | 52 +++- pkg/cmdutil/flags.go | 26 -- pkg/cmdutil/flags_hint_test.go | 10 +- pkg/cmdutil/provenance.go | 22 -- test/smoke/cli_smoke_test.go | 21 ++ 61 files changed, 1830 insertions(+), 735 deletions(-) create mode 100644 internal/app/group_policy_tree_test.go create mode 100644 internal/corecmd/group_policy.go create mode 100644 internal/corecmd/group_policy_test.go delete mode 100644 internal/helpers/group_command_declaration_test.go create mode 100644 pkg/cmdutil/command_resolution.go create mode 100644 pkg/cmdutil/command_resolution_test.go diff --git a/AGENTS.md b/AGENTS.md index 98f247fd..a8a2a21a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,8 @@ Schema contract) keep separate authorities — do not merge them with ## Command framework declaration - Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0** -- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New` +- Today (leaf): `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New` +- Today (non-leaf): owning Cobra command → complete `corecmd.GroupPolicy{Mode, Positionals, Recovery}` → `corecmd.ApplyGroupPolicy`; the final assembled-tree gate rejects undeclared groups and stale group declarations on leaves - **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`) - Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`. - `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final. @@ -60,6 +61,7 @@ Schema contract) keep separate authorities — do not merge them with - **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**) - **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed) - Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind. +- Group policy is separate from the leaf tiers: `corecmd.Spec` remains leaf-only. `ApplyGroupPolicy` must not infer or enable `TraverseChildren`; parent local-flag inheritance remains an explicit owning-command surface. - Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority. - **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority - Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared diff --git a/docs/rfc-command-framework-convergence.md b/docs/rfc-command-framework-convergence.md index cb12c50b..e98f7914 100644 --- a/docs/rfc-command-framework-convergence.md +++ b/docs/rfc-command-framework-convergence.md @@ -274,6 +274,7 @@ Definition(仅声明;不可编译) | 层 | 含义 | 今日落点 | |---|---|---| | **声明(declare)** | `corecmd.Spec` / `LeafSpec` / `ContractDecl` **数据字段**(声明证据;交付见下) | `Flags`/`Constraints`/`Risk`/`ConstParams`/`Contract`;类型真身在 `corecmd/contract`(DTO:`SafetySpec`/`ParamDecl`/`ProductDecl`/`ContractFinalPayload`;**无** Cobra store) | +| **非叶声明(group declare)** | owning Cobra 命令上的完整 `corecmd.GroupPolicy`;不是 leaf `Spec` 字段 | `Mode` / `Positionals` / `Recovery` 经 `corecmd.ApplyGroupPolicy` 一次编译为 Cobra 行为与私有框架元数据 | | **框架转换** | 类型转换并注册(**禁止** JSON 注解桥) | `embedContractDecl` → `corecmd/contractfinal.RegisterRuntimeContractFinal`(annotate + store;全部调用方直调,`corecmd.New` 内部注册) | | **注解 seam** | Cobra `dws.schema.*` 写入 | `internal/corecmd/runtimeannotate.AnnotateRuntime*`(框架侧;`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/runtimeannotate` 垫片包已删,一律直引 corecmd) | | **Schema 透传** / 交付 | 组装读取注册表,原样投影为 `ToolSpec`;`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`(`ResolveMeta` 自同一组装投影);go:embed 仅限 reviewed 输入(MCP meta / `param_concepts` 等;reviewed `schema_command_registry/` 已退役,identity 由 collector 收集),映射排除走 Go ledger(`schema_parameter_mapping_ledger.go`),不得 embed Catalog | `internal/cli` 根(交付边界);ContractFinal store 在 `corecmd/contractfinal`(`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/contractfinal` 垫片包已删) | @@ -310,7 +311,25 @@ Definition(仅声明;不可编译) 3. 写副作用:新 Leaf 声明完整 `SafetySpec`(框架 `ConfirmSafety` + Schema Final);未迁移旧路径显式标注 `runtime_gate`;二者皆无则不合格; 4. Schema `ToolSpec` 全字段组均落在 §5.0.4 表中某一权威格,禁止无主字段。 -#### 5.0.2a 三档声明路径(Tier1 / Tier2 / Tier3) +#### 5.0.2a 非叶命令契约(`corecmd.GroupPolicy`) + +`corecmd.Spec` / `LeafSpec` 继续只定义叶命令。每个拥有子命令的 owning Cobra 命令必须在构造处通过 `corecmd.ApplyGroupPolicy` 声明一份完整 `GroupPolicy`: + +| 轴 | 允许值 | 语义 | +|---|---|---| +| `Mode` | `navigation_only` / `hybrid` | 仅导航并展示帮助,或同时保留本命令业务执行 | +| `Positionals` | `reject` / `allow` | 未匹配 token 进入命令恢复,或由本命令业务位置参数消费 | +| `Recovery` | `sibling` / `deep` / `disabled` | 只建议直接子命令、显式允许后代路径恢复,或完全关闭恢复 | + +硬规则: + +1. 三个字段必须同时声明;全零值只表示 leaf,不能应用到命令。`navigation_only` 必须 `Positionals=reject`;`Positionals=allow` 必须 `Recovery=disabled`,避免业务 argv 与命令恢复争抢同一 token。 +2. `ApplyGroupPolicy` 是唯一编译入口:navigation 安装统一 help/错误 handler;hybrid 保留 owning `RunE`,仅在声明拒绝 positionals 且开启恢复时包裹 unknown-command 分支。恢复统一投影为有界 `CommandResolution`(最多 3 个建议 + 当前 parent `--help`);只有 `Recovery=deep` 才可建议完整后代路径。 +3. `GroupPolicy` **不推导** `TraverseChildren`。该 Cobra 字段会改变父级 local flag 是否向子命令传播,必须由原 owning command 显式保留,不能因迁移到 typo guidance 而扩大参数表面。 +4. 最终装配树门禁检查「有 children 必须有 GroupPolicy、leaf 不得残留 GroupPolicy、navigation/hybrid handler 与声明结构一致」。门禁不执行任意 `Args` 函数;`ApplyGroupPolicy` 对 `cobra.NoArgs` / `cobra.ArbitraryArgs` 的编译由 corecmd 单测覆盖。 +5. 命令树合并时,两侧非空 group 都必须先声明 policy;冲突声明、group 与 runnable/parse-bearing leaf 合并、或带 children 的未声明节点均 fail closed。纯 metadata 空壳可采用 typed source policy,不能借此吞掉 flags、hooks 或执行体。 + +#### 5.0.2b 三档叶声明路径(Tier1 / Tier2 / Tier3) 当前生产允许的三档路径(同一 `ContractFinal` 语义;不是互相否定): diff --git a/internal/app/group_policy_tree_test.go b/internal/app/group_policy_tree_test.go new file mode 100644 index 00000000..eadabcd9 --- /dev/null +++ b/internal/app/group_policy_tree_test.go @@ -0,0 +1,55 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" + "github.com/spf13/cobra" +) + +// TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy replaces the +// old helpers-only AST scan with an invariant over the two real assembly +// products: the deterministic distribution tree and a runtime tree after a +// nested plugin overlay has been merged. +func TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy(t *testing.T) { + distribution := NewSchemaSourceRootCommand() + for _, path := range []string{ + "sheet range read", + "pat chmod", + "plugin list", + "chat +chat-messages", + } { + requireFinalCommandPath(t, distribution, path) + } + if err := cobracmd.ValidateGroupTree(distribution); err != nil { + t.Fatalf("distribution command tree GroupPolicy invariant: %v", err) + } + + t.Setenv("DWS_CONFIG_DIR", t.TempDir()) + testseam.Swap(t, &rootLoadPlugins, func(root *cobra.Command, _ *pipeline.Engine, runner executor.Runner) []*cobra.Command { + descriptor := conferencePluginDescriptor() + return buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, root) + }) + runtime := NewRootCommand() + requireFinalCommandPath(t, runtime, "conference camera open") + if err := cobracmd.ValidateGroupTree(runtime); err != nil { + t.Fatalf("runtime command tree GroupPolicy invariant: %v", err) + } +} + +func requireFinalCommandPath(t *testing.T, root *cobra.Command, path string) *cobra.Command { + t.Helper() + command, remaining, err := root.Find(strings.Fields(path)) + if err != nil || command == nil || len(remaining) != 0 || command == root { + t.Fatalf("final command path %q not assembled: command=%v remaining=%v err=%v", path, command, remaining, err) + } + return command +} diff --git a/internal/app/mcp_url_command.go b/internal/app/mcp_url_command.go index c6bf7d45..2f958726 100644 --- a/internal/app/mcp_url_command.go +++ b/internal/app/mcp_url_command.go @@ -21,10 +21,10 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -44,7 +44,11 @@ func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(group) + corecmd.ApplyGroupPolicy(group, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) group.AddCommand(newMCPURLGetCommand(caller)) return group } diff --git a/internal/app/plugin_commands.go b/internal/app/plugin_commands.go index 73b0db79..94c851f7 100644 --- a/internal/app/plugin_commands.go +++ b/internal/app/plugin_commands.go @@ -24,6 +24,7 @@ import ( "unicode" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" @@ -747,7 +748,11 @@ func pruneEmptyPluginGroups(parent *cobra.Command) { } for _, child := range append([]*cobra.Command(nil), parent.Commands()...) { pruneEmptyPluginGroups(child) - if cmdutil.IsGroup(child) && len(child.Commands()) == 0 { + _, group, err := corecmd.GroupPolicyFor(child) + if err != nil { + panic(fmt.Sprintf("prune plugin group %q: %v", child.CommandPath(), err)) + } + if group && len(child.Commands()) == 0 { parent.RemoveCommand(child) } } diff --git a/internal/app/plugin_commands_coverage_test.go b/internal/app/plugin_commands_coverage_test.go index ca09b268..02f5d222 100644 --- a/internal/app/plugin_commands_coverage_test.go +++ b/internal/app/plugin_commands_coverage_test.go @@ -507,7 +507,8 @@ func TestPluginConstraintGroupAndRootHelpers(t *testing.T) { mergePluginRoot(nil, root) mergePluginRoot(root, nil) destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}} - source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}} + source := cobracmd.NewGroupCommand("plugin", "plugin") + source.Aliases = []string{"one", "two"} source.AddCommand(&cobra.Command{Use: "leaf"}) mergePluginRoot(destination, source) if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil { diff --git a/internal/app/profile_command_test.go b/internal/app/profile_command_test.go index de0950b1..32e3b428 100644 --- a/internal/app/profile_command_test.go +++ b/internal/app/profile_command_test.go @@ -23,6 +23,7 @@ import ( "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" @@ -653,8 +654,11 @@ func TestAuthCommandDoesNotExposeSwitch(t *testing.T) { if err == nil { t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String()) } - if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) { - t.Fatalf("error = %v, want auth switch unknown command", err) + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" || + structured.Message != `unknown subcommand "switch" for "dws auth"` || + structured.Hint != "Run 'dws auth --help' for the full list" { + t.Fatalf("error = %#v, want bounded auth subcommand guidance", err) } } diff --git a/internal/app/root.go b/internal/app/root.go index 544d4b16..0827c5c2 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -30,6 +30,7 @@ import ( authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" @@ -924,6 +925,11 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo return nil }, } + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) bindPersistentFlags(root, flags) @@ -935,30 +941,42 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags)) mcpCmd.AddCommand(newMCPURLGroup(patCaller)) - utilityGroup := func(command *cobra.Command) *cobra.Command { - cmdutil.MarkGroup(command) + navigationGroup := func(command *cobra.Command) *cobra.Command { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + return command + } + hybridGroup := func(command *cobra.Command) *cobra.Command { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) return command } utilityCommands := []*cobra.Command{ - utilityGroup(newAuthCommand(patCaller)), - utilityGroup(newProfileCommand()), + navigationGroup(newAuthCommand(patCaller)), + navigationGroup(newProfileCommand()), newAPICommand(flags), - utilityGroup(newSkillCommand()), - utilityGroup(newCacheCommand()), + navigationGroup(newSkillCommand()), + hybridGroup(newCacheCommand()), newCatalogCommand(), - utilityGroup(newConfigCommand()), + navigationGroup(newConfigCommand()), newDoctorCommand(), - utilityGroup(newRecoveryCommand()), - utilityGroup(newEventCommand(flags)), - utilityGroup(newAuditCommand()), + hybridGroup(newRecoveryCommand()), + navigationGroup(newEventCommand(flags)), + navigationGroup(newAuditCommand()), newCompletionCommand(root), newUpgradeCommand(), newVersionCommand(), newPluginCommand(), - utilityGroup(usage.NewShortcutCommand()), + navigationGroup(usage.NewShortcutCommand()), schemaCmd, - utilityGroup(mcpCmd), + navigationGroup(mcpCmd), } root.AddCommand(utilityCommands...) diff --git a/internal/cobracmd/tree.go b/internal/cobracmd/tree.go index e395162e..60de616e 100644 --- a/internal/cobracmd/tree.go +++ b/internal/cobracmd/tree.go @@ -16,9 +16,10 @@ package cobracmd import ( + "fmt" "strings" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -47,17 +48,14 @@ func NewGroupCommand(use, short string) *cobra.Command { cmd := &cobra.Command{ Use: use, Short: short, - Args: cobra.NoArgs, TraverseChildren: true, DisableAutoGenTag: true, - RunE: func(cmd *cobra.Command, args []string) error { - return cmd.Help() - }, } - // Tag as a group container: its RunE only prints help, so cobra's - // Runnable() can't distinguish it from a real leaf — callers that need to - // collapse empty groups rely on this annotation. - cmdutil.MarkGroup(cmd) + corecmd.ApplyGroupPolicy(cmd, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) return cmd } @@ -90,6 +88,7 @@ func MergeCommandTree(dst, src *cobra.Command) { if dst == nil || src == nil { return } + mergeGroupPolicy(dst, src) if dst.Short == "" || (IsGenericOverlayShort(dst.Short) && src.Short != "" && !IsGenericOverlayShort(src.Short)) { dst.Short = src.Short } @@ -113,6 +112,137 @@ func MergeCommandTree(dst, src *cobra.Command) { } } +func mergeGroupPolicy(dst, src *cobra.Command) { + dstPolicy, dstOK, err := corecmd.GroupPolicyFor(dst) + if err != nil { + panic(fmt.Sprintf("destination command %q has invalid GroupPolicy: %v", dst.CommandPath(), err)) + } + srcPolicy, srcOK, err := corecmd.GroupPolicyFor(src) + if err != nil { + panic(fmt.Sprintf("source command %q has invalid GroupPolicy: %v", src.CommandPath(), err)) + } + if len(dst.Commands()) > 0 && !dstOK { + panic(fmt.Sprintf("destination command %q has children but no GroupPolicy", dst.CommandPath())) + } + if len(src.Commands()) > 0 && !srcOK { + panic(fmt.Sprintf("source command %q has children but no GroupPolicy", src.CommandPath())) + } + if dstOK && !srcOK { + if !isNeutralMergePlaceholder(src) { + panic(fmt.Sprintf("cannot merge runnable or behavior-bearing leaf command %q into typed group command %q", + src.CommandPath(), dst.CommandPath())) + } + return + } + if !dstOK && srcOK { + if !isNeutralMergePlaceholder(dst) { + panic(fmt.Sprintf("cannot merge typed group command %q into runnable or behavior-bearing leaf command %q", + src.CommandPath(), dst.CommandPath())) + } + corecmd.ApplyGroupPolicy(dst, srcPolicy) + return + } + if dstOK && srcOK && dstPolicy != srcPolicy { + // A NavigationOnly/Reject/Sibling source is the framework's neutral + // service scaffold (shortcuts and plugin overlays use it before being + // folded into an owning product root). The destination owns the merged + // command's default action and recovery scope, so preserve its policy. + // Any stronger source declaration would lose behavior during this + // destination-oriented merge and therefore fails closed. + if srcPolicy != (corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) { + panic(fmt.Sprintf("cannot merge command %q with conflicting GroupPolicy declarations: %+v != %+v", + dst.CommandPath(), dstPolicy, srcPolicy)) + } + return + } +} + +// isNeutralMergePlaceholder reports whether cmd contributes metadata only. +// Such a shell may adopt (or be folded into) one typed group declaration. +// Anything executable, parse-affecting, or child-bearing must declare its own +// compatible GroupPolicy so tree assembly cannot silently discard behavior. +func isNeutralMergePlaceholder(cmd *cobra.Command) bool { + if cmd == nil || len(cmd.Commands()) != 0 || cmd.Runnable() || cmd.Args != nil || + cmd.PreRun != nil || cmd.PreRunE != nil || cmd.PostRun != nil || cmd.PostRunE != nil || + cmd.PersistentPreRun != nil || cmd.PersistentPreRunE != nil || + cmd.PersistentPostRun != nil || cmd.PersistentPostRunE != nil || + cmd.TraverseChildren || cmd.DisableFlagParsing { + return false + } + hasFlags := false + cmd.LocalNonPersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true }) + cmd.PersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true }) + return !hasFlags +} + +// ValidateGroupTree checks the final assembled Cobra tree rather than source +// syntax. Every command with children must carry one valid typed GroupPolicy; +// leaves must carry none. This catches dynamically assembled aliases, plugin +// parents, and constructors outside any one source directory. +func ValidateGroupTree(root *cobra.Command) error { + if root == nil { + return fmt.Errorf("cannot validate a nil command tree") + } + return validateGroupNode(root) +} + +func validateGroupNode(cmd *cobra.Command) error { + policy, declared, err := corecmd.GroupPolicyFor(cmd) + if err != nil { + return fmt.Errorf("command %q has invalid GroupPolicy metadata: %w", cmd.CommandPath(), err) + } + children := cmd.Commands() + if len(children) == 0 { + if declared { + return fmt.Errorf("leaf command %q retains GroupPolicy %+v", cmd.CommandPath(), policy) + } + return nil + } + if !declared { + return fmt.Errorf("command %q has children but no GroupPolicy", cmd.CommandPath()) + } + if !cmd.Runnable() { + return fmt.Errorf("group command %q with mode %q is not runnable", cmd.CommandPath(), policy.Mode) + } + if policy.Mode == corecmd.GroupNavigationOnly && (cmd.RunE == nil || cmd.Run != nil) { + return fmt.Errorf("navigation-only group %q does not retain framework help execution", cmd.CommandPath()) + } + if policy.Mode == corecmd.GroupHybrid && cmd.RunE == nil { + return fmt.Errorf("hybrid group %q lost its business RunE", cmd.CommandPath()) + } + if policy.Positionals == corecmd.PositionalsAllow && cmd.Args == nil { + return fmt.Errorf("group command %q allows positionals without an explicit Args contract", cmd.CommandPath()) + } + if policy.Positionals == corecmd.PositionalsReject { + if cmd.Args == nil { + return fmt.Errorf("group command %q rejects positionals without compiled Args behavior", cmd.CommandPath()) + } + } + if policy.Recovery == corecmd.RecoveryDeep && !hasAvailableDescendant(cmd) { + return fmt.Errorf("group command %q declares deep recovery without an available descendant", cmd.CommandPath()) + } + for _, child := range children { + if err := validateGroupNode(child); err != nil { + return err + } + } + return nil +} + +func hasAvailableDescendant(cmd *cobra.Command) bool { + for _, child := range cmd.Commands() { + if !child.IsAvailableCommand() { + continue + } + return true + } + return false +} + // ShouldReplaceLeaf decides whether src should replace dst as a leaf command // based on override priority and local flag count. func ShouldReplaceLeaf(dst, src *cobra.Command) bool { diff --git a/internal/cobracmd/tree_test.go b/internal/cobracmd/tree_test.go index 3ca75901..0d3e61c1 100644 --- a/internal/cobracmd/tree_test.go +++ b/internal/cobracmd/tree_test.go @@ -14,8 +14,10 @@ package cobracmd import ( + "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/spf13/cobra" ) @@ -143,20 +145,290 @@ func TestNewGroupCommand(t *testing.T) { t.Fatalf("Short = %q, want %q", cmd.Short, "my group description") } if cmd.Args == nil { - t.Fatal("Args should be set (cobra.NoArgs)") + t.Fatal("Args should be set (cobra.ArbitraryArgs)") } - // Verify Args rejects arguments. - if err := cmd.Args(cmd, []string{"extra"}); err == nil { - t.Fatal("expected Args to reject extra arguments") + // Args must reach the shared resolver instead of Cobra's generic arg error. + if err := cmd.Args(cmd, []string{"extra"}); err != nil { + t.Fatalf("Args intercepted command resolution: %v", err) } // Verify RunE is set and returns help (no error for valid invocation). if cmd.RunE == nil { t.Fatal("RunE should not be nil") } + policy, ok, err := corecmd.GroupPolicyFor(cmd) + if err != nil || !ok || policy != (corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) { + t.Fatalf("GroupPolicyFor() = %+v, %v, %v", policy, ok, err) + } // RunE calls cmd.Help() which should not error. if err := cmd.RunE(cmd, nil); err != nil { t.Fatalf("RunE returned unexpected error: %v", err) } + if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") { + t.Fatalf("RunE typo error = %v", err) + } +} + +func TestCrossPlatformCoverageValidateGroupTree(t *testing.T) { + t.Run("valid final tree", func(t *testing.T) { + root := NewGroupCommand("dws", "root") + nested := NewGroupCommand("nested", "nested") + nested.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.AddCommand(nested) + if err := ValidateGroupTree(root); err != nil { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("nil tree", func(t *testing.T) { + if err := ValidateGroupTree(nil); err == nil || !strings.Contains(err.Error(), "nil") { + t.Fatalf("ValidateGroupTree(nil) = %v", err) + } + }) + + t.Run("children require declaration", func(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "no GroupPolicy") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("leaf rejects stale declaration", func(t *testing.T) { + leaf := NewGroupCommand("stale", "stale") + if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("deep policy on a leaf is still a stale group declaration", func(t *testing.T) { + leaf := &cobra.Command{Use: "stale-deep"} + corecmd.ApplyGroupPolicy(leaf, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + }) + if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("declared group must stay runnable", func(t *testing.T) { + root := NewGroupCommand("dws", "root") + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.RunE = nil + root.Run = nil + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "not runnable") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("rejected positionals require compiled Args behavior", func(t *testing.T) { + root := NewGroupCommand("dws", "root") + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.Args = nil + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "compiled Args") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("allowed positionals require explicit Args contract", func(t *testing.T) { + root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }} + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled, + }) + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "explicit Args") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("validation does not execute positional contracts", func(t *testing.T) { + calls := 0 + root := &cobra.Command{ + Use: "dws", + Args: func(*cobra.Command, []string) error { + calls++ + return nil + }, + RunE: func(*cobra.Command, []string) error { return nil }, + } + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled, + }) + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + if err := ValidateGroupTree(root); err != nil { + t.Fatalf("ValidateGroupTree() = %v", err) + } + if calls != 0 { + t.Fatalf("ValidateGroupTree executed Args %d times", calls) + } + }) + + t.Run("deep recovery requires available descendants", func(t *testing.T) { + root := &cobra.Command{Use: "dws"} + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + }) + root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }}) + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "available descendant") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) +} + +func TestCrossPlatformCoverageMergeCommandTreeGroupPolicy(t *testing.T) { + t.Run("copies source declaration", func(t *testing.T) { + dst := &cobra.Command{Use: "root"} + src := NewGroupCommand("root", "source") + MergeCommandTree(dst, src) + policy, ok, err := corecmd.GroupPolicyFor(dst) + if err != nil || !ok || policy.Recovery != corecmd.RecoverySibling { + t.Fatalf("merged policy = %+v, %v, %v", policy, ok, err) + } + }) + + t.Run("typed destination accepts metadata-only source", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := &cobra.Command{Use: "root", Long: "source details"} + MergeCommandTree(dst, src) + if dst.Long != "source details" { + t.Fatalf("Long = %q", dst.Long) + } + }) + + t.Run("accepts identical declarations", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := NewGroupCommand("root", "source") + MergeCommandTree(dst, src) + }) + + t.Run("neutral scaffold preserves owning hybrid deep policy", func(t *testing.T) { + businessCalled := false + dst := &cobra.Command{ + Use: "root", + RunE: func(*cobra.Command, []string) error { + businessCalled = true + return nil + }, + } + want := corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + } + corecmd.ApplyGroupPolicy(dst, want) + dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }}) + + src := NewGroupCommand("root", "neutral scaffold") + src.AddCommand(&cobra.Command{Use: "overlay", RunE: func(*cobra.Command, []string) error { return nil }}) + MergeCommandTree(dst, src) + + got, ok, err := corecmd.GroupPolicyFor(dst) + if err != nil || !ok || got != want { + t.Fatalf("merged owning policy = %+v, %v, %v; want %+v", got, ok, err, want) + } + if ChildByName(dst, "overlay") == nil { + t.Fatal("neutral scaffold child was not merged") + } + if err := dst.RunE(dst, nil); err != nil || !businessCalled { + t.Fatalf("owning Hybrid RunE was not preserved: called=%v err=%v", businessCalled, err) + } + }) + + t.Run("rejects conflicting declarations", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := &cobra.Command{Use: "root"} + corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled, + }) + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, src) + }) + + t.Run("hybrid deep target rejects non-neutral source", func(t *testing.T) { + dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }} + corecmd.ApplyGroupPolicy(dst, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + }) + src := &cobra.Command{Use: "root"} + corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + }) + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, src) + }) + + t.Run("does not overwrite undeclared runnable destination", func(t *testing.T) { + dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }} + src := NewGroupCommand("root", "source") + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, src) + }) + + t.Run("does not swallow runnable leaf source into group destination", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }}) + src := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }} + src.Flags().String("source-only", "", "must not be silently discarded") + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, src) + }) + + t.Run("does not swallow parse behavior from source into group destination", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := &cobra.Command{Use: "root", Args: cobra.NoArgs} + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, src) + }) + + t.Run("rejects undeclared destination group", func(t *testing.T) { + dst := &cobra.Command{Use: "root"} + dst.AddCommand(&cobra.Command{Use: "child"}) + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "destination command") || !strings.Contains(got.(string), "no GroupPolicy") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(dst, &cobra.Command{Use: "root"}) + }) + + t.Run("rejects undeclared source group", func(t *testing.T) { + src := &cobra.Command{Use: "root"} + src.AddCommand(&cobra.Command{Use: "child"}) + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), "source command") || !strings.Contains(got.(string), "no GroupPolicy") { + t.Fatalf("MergeCommandTree panic = %v", got) + } + }() + MergeCommandTree(&cobra.Command{Use: "root"}, src) + }) } func TestNewHiddenGroupCommand(t *testing.T) { @@ -341,11 +613,11 @@ func TestMergeCommandTree(t *testing.T) { t.Run("child merge recursive", func(t *testing.T) { t.Parallel() - dst := &cobra.Command{Use: "root"} + dst := NewGroupCommand("root", "destination") dstChild := &cobra.Command{Use: "sub", Short: ""} dst.AddCommand(dstChild) - src := &cobra.Command{Use: "root"} + src := NewGroupCommand("root", "source") srcChild := &cobra.Command{Use: "sub", Short: "Merged short"} src.AddCommand(srcChild) @@ -361,12 +633,12 @@ func TestMergeCommandTree(t *testing.T) { t.Run("leaf replacement by higher priority", func(t *testing.T) { t.Parallel() - dst := &cobra.Command{Use: "root"} + dst := NewGroupCommand("root", "destination") dstLeaf := &cobra.Command{Use: "leaf", Short: "old"} SetOverridePriority(dstLeaf, 1) dst.AddCommand(dstLeaf) - src := &cobra.Command{Use: "root"} + src := NewGroupCommand("root", "source") srcLeaf := &cobra.Command{Use: "leaf", Short: "new"} SetOverridePriority(srcLeaf, 5) src.AddCommand(srcLeaf) @@ -383,10 +655,10 @@ func TestMergeCommandTree(t *testing.T) { t.Run("new child addition", func(t *testing.T) { t.Parallel() - dst := &cobra.Command{Use: "root"} + dst := NewGroupCommand("root", "destination") dst.AddCommand(&cobra.Command{Use: "existing"}) - src := &cobra.Command{Use: "root"} + src := NewGroupCommand("root", "source") src.AddCommand(&cobra.Command{Use: "brand-new", Short: "added"}) MergeCommandTree(dst, src) diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index b94cc080..abf3260e 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -11,11 +11,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Package corecmd is the shared, dispatch-agnostic base for building leaf -// commands. It concentrates flag registration, the alias/env/default effective -// value fallback chain, required validation, cross-flag constraint declaration -// checks + runtime enforcement, SafetySpec-driven confirmation, toolArgs -// assembly, and Agent Runtime Schema projection. +// Package corecmd is the shared, dispatch-agnostic base for building commands. +// It concentrates typed group policy, flag registration, the alias/env/default +// effective value fallback chain, required validation, cross-flag constraint +// declaration checks + runtime enforcement, SafetySpec-driven confirmation, +// toolArgs assembly, and Agent Runtime Schema projection. // // Declaration vs execution (framework rule): // @@ -231,7 +231,9 @@ const ( // or assemble business params that belong in Flags/ConstParams. // // Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New -// validates this at construction time. corecmd stays dispatch-agnostic and +// validates this at construction time. Non-leaf commands are declared +// separately through ApplyGroupPolicy so leaf execution fields can never be +// configured and then silently ignored. corecmd stays dispatch-agnostic and // never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the // body. type Spec struct { diff --git a/internal/corecmd/group_policy.go b/internal/corecmd/group_policy.go new file mode 100644 index 00000000..e79cec07 --- /dev/null +++ b/internal/corecmd/group_policy.go @@ -0,0 +1,218 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package corecmd + +import ( + "fmt" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +// GroupMode declares whether a command with children is navigation-only or +// also owns business execution. The zero value means the command is a leaf. +type GroupMode string + +const ( + // GroupNavigationOnly is a parent whose own invocation only renders help. + GroupNavigationOnly GroupMode = "navigation_only" + // GroupHybrid is a runnable business command that also owns children. + GroupHybrid GroupMode = "hybrid" +) + +// PositionalsPolicy declares whether a group may consume positional values. +type PositionalsPolicy string + +const ( + // PositionalsReject makes every unmatched positional token eligible for + // command-resolution recovery rather than business execution. + PositionalsReject PositionalsPolicy = "reject" + // PositionalsAllow reserves positional values for the group's business + // execution. Recovery must therefore be disabled to avoid ambiguity. + PositionalsAllow PositionalsPolicy = "allow" +) + +// RecoveryPolicy declares the search scope for unknown-command recovery. +type RecoveryPolicy string + +const ( + // RecoverySibling suggests only direct children of the current group. + RecoverySibling RecoveryPolicy = "sibling" + // RecoveryDeep may search all descendants of the current group. + RecoveryDeep RecoveryPolicy = "deep" + // RecoveryDisabled leaves positional handling entirely to Cobra or the + // command's business execution. + RecoveryDisabled RecoveryPolicy = "disabled" +) + +// GroupPolicy is the typed declaration for every non-leaf command. +// +// Its zero value deliberately means "leaf": callers must declare all three +// fields together for a group. ApplyGroupPolicy compiles the declaration to +// Cobra behavior and private framework metadata; command authors must not +// author parallel kind annotations themselves. +type GroupPolicy struct { + Mode GroupMode + Positionals PositionalsPolicy + Recovery RecoveryPolicy +} + +const groupPolicyAnnotation = "dws.internal.corecmd.group_policy.v1" + +// IsZero reports whether p is the leaf declaration. +func (p GroupPolicy) IsZero() bool { + return p.Mode == "" && p.Positionals == "" && p.Recovery == "" +} + +// ValidateGroupPolicy rejects partial declarations, unknown enum values, and +// combinations whose parsing semantics would be ambiguous. +func ValidateGroupPolicy(p GroupPolicy) error { + if p.IsZero() { + return nil + } + switch p.Mode { + case GroupNavigationOnly, GroupHybrid: + default: + return fmt.Errorf("invalid group mode %q", p.Mode) + } + switch p.Positionals { + case PositionalsReject, PositionalsAllow: + default: + return fmt.Errorf("invalid group positionals policy %q", p.Positionals) + } + switch p.Recovery { + case RecoverySibling, RecoveryDeep, RecoveryDisabled: + default: + return fmt.Errorf("invalid group recovery policy %q", p.Recovery) + } + if p.Mode == GroupNavigationOnly && p.Positionals != PositionalsReject { + return fmt.Errorf("navigation-only group requires positionals=%q", PositionalsReject) + } + if p.Positionals == PositionalsAllow && p.Recovery != RecoveryDisabled { + return fmt.Errorf("group with positionals=%q requires recovery=%q", PositionalsAllow, RecoveryDisabled) + } + return nil +} + +// ApplyGroupPolicy is the sole declaration API for non-leaf command behavior. +// Invalid declarations panic because they are framework construction bugs, +// matching the fail-closed behavior of Spec flag/constraint declarations. +// +// Navigation-only groups receive the shared help/unknown-command RunE. Hybrid +// groups retain their existing RunE; when they reject positionals, a wrapper +// sends non-empty args to the same unknown-command resolver before invoking +// business execution. When recovery is enabled, rejecting positionals +// deliberately compiles to cobra.ArbitraryArgs: Cobra must not intercept the +// token with a generic error before command resolution can produce bounded +// guidance. RecoveryDisabled instead compiles rejection to cobra.NoArgs. +func ApplyGroupPolicy(cmd *cobra.Command, policy GroupPolicy) { + if cmd == nil { + panic("cannot apply GroupPolicy to a nil command") + } + if policy.IsZero() { + panic(fmt.Sprintf("command %q cannot apply the zero GroupPolicy; zero means leaf", cmd.Name())) + } + if err := ValidateGroupPolicy(policy); err != nil { + panic(fmt.Sprintf("command %q declares invalid GroupPolicy: %v", cmd.Name(), err)) + } + if existing, ok, err := GroupPolicyFor(cmd); err != nil { + panic(fmt.Sprintf("command %q carries invalid GroupPolicy metadata: %v", cmd.Name(), err)) + } else if ok && existing != policy { + panic(fmt.Sprintf("command %q redeclares GroupPolicy from %+v to %+v", cmd.Name(), existing, policy)) + } else if ok { + return + } + + if policy.Mode == GroupNavigationOnly { + cmd.Run = nil + cmd.RunE = func(cmd *cobra.Command, args []string) error { + return runGroupPolicy(cmd, args, policy) + } + } else if cmd.RunE == nil { + panic(fmt.Sprintf("hybrid group %q must declare RunE before GroupPolicy is applied", cmd.Name())) + } else if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled { + businessRunE := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return runGroupPolicy(cmd, args, policy) + } + return businessRunE(cmd, args) + } + } + if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled { + cmd.Args = cobra.ArbitraryArgs + } else if policy.Positionals == PositionalsReject { + cmd.Args = cobra.NoArgs + } + + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[groupPolicyAnnotation] = encodeGroupPolicy(policy) +} + +// GroupPolicyFor reads the typed declaration compiled onto cmd. The boolean is +// false only for a leaf. Malformed private metadata is returned as an error so +// tree assembly can fail closed instead of silently treating a group as a leaf. +func GroupPolicyFor(cmd *cobra.Command) (GroupPolicy, bool, error) { + if cmd == nil || cmd.Annotations == nil { + return GroupPolicy{}, false, nil + } + raw, ok := cmd.Annotations[groupPolicyAnnotation] + if !ok { + return GroupPolicy{}, false, nil + } + parts := strings.Split(raw, "|") + if len(parts) != 3 { + return GroupPolicy{}, false, fmt.Errorf("malformed encoded GroupPolicy %q", raw) + } + policy := GroupPolicy{ + Mode: GroupMode(parts[0]), + Positionals: PositionalsPolicy(parts[1]), + Recovery: RecoveryPolicy(parts[2]), + } + if err := ValidateGroupPolicy(policy); err != nil { + return GroupPolicy{}, false, err + } + if policy.IsZero() { + return GroupPolicy{}, false, fmt.Errorf("encoded GroupPolicy must not be zero") + } + return policy, true, nil +} + +func encodeGroupPolicy(policy GroupPolicy) string { + return string(policy.Mode) + "|" + string(policy.Positionals) + "|" + string(policy.Recovery) +} + +func runGroupPolicy(cmd *cobra.Command, args []string, policy GroupPolicy) error { + if len(args) == 0 { + return cmd.Help() + } + input := strings.TrimSpace(args[0]) + reason := cmdutil.ClassifyCommandResolution(cmd, input) + suggestions := cmdutil.SuggestSubcommands(cmd, input) + if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == RecoveryDeep { + if deep := cmdutil.SuggestDescendantSubcommands(cmd, input); len(deep) > 0 { + suggestions = deep + } + } + return cmdutil.NewCommandResolution( + cmd, + input, + reason, + suggestions, + "", + ).Err() +} diff --git a/internal/corecmd/group_policy_test.go b/internal/corecmd/group_policy_test.go new file mode 100644 index 00000000..a184f29b --- /dev/null +++ b/internal/corecmd/group_policy_test.go @@ -0,0 +1,286 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package corecmd + +import ( + "errors" + "runtime" + "slices" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageValidateGroupPolicy(t *testing.T) { + valid := []GroupPolicy{ + {}, + {Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}, + {Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep}, + {Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled}, + {Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling}, + {Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDeep}, + {Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDisabled}, + {Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoveryDisabled}, + } + for _, policy := range valid { + if err := ValidateGroupPolicy(policy); err != nil { + t.Fatalf("ValidateGroupPolicy(%+v) = %v", policy, err) + } + } + + invalid := []struct { + name string + policy GroupPolicy + needle string + }{ + {name: "partial", policy: GroupPolicy{Mode: GroupHybrid}, needle: "positionals"}, + {name: "unknown mode", policy: GroupPolicy{Mode: "leafish", Positionals: PositionalsReject, Recovery: RecoveryDisabled}, needle: "mode"}, + {name: "unknown positionals", policy: GroupPolicy{Mode: GroupHybrid, Positionals: "maybe", Recovery: RecoveryDisabled}, needle: "positionals"}, + {name: "unknown recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: "global"}, needle: "recovery"}, + {name: "navigation allows args", policy: GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsAllow, Recovery: RecoveryDisabled}, needle: "navigation-only"}, + {name: "ambiguous recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoverySibling}, needle: "requires recovery"}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if err := ValidateGroupPolicy(tc.policy); err == nil || !strings.Contains(err.Error(), tc.needle) { + t.Fatalf("ValidateGroupPolicy(%+v) = %v, want %q", tc.policy, err, tc.needle) + } + }) + } +} + +func TestCrossPlatformCoverageApplyAndReadGroupPolicy(t *testing.T) { + policy := GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling} + cmd := &cobra.Command{Use: "parent"} + ApplyGroupPolicy(cmd, policy) + + got, ok, err := GroupPolicyFor(cmd) + if err != nil || !ok || got != policy { + t.Fatalf("GroupPolicyFor() = %+v, %v, %v; want %+v, true, nil", got, ok, err, policy) + } + if !cmd.Runnable() || cmd.TraverseChildren { + t.Fatalf("compiled navigation command Runnable=%v TraverseChildren=%v; policy must preserve the flag-traversal surface", cmd.Runnable(), cmd.TraverseChildren) + } + if cmd.Args == nil || cmd.Args(cmd, []string{"extra"}) != nil { + t.Fatal("PositionalsReject must let command resolution inspect unmatched args") + } + if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") { + t.Fatalf("navigation recovery error = %v", err) + } + // Re-applying the same declaration is idempotent. + ApplyGroupPolicy(cmd, policy) + + traversing := &cobra.Command{Use: "traversing", TraverseChildren: true} + ApplyGroupPolicy(traversing, policy) + if !traversing.TraverseChildren { + t.Fatal("ApplyGroupPolicy changed an explicitly declared TraverseChildren surface") + } + + leaf := &cobra.Command{Use: "leaf"} + if got, ok, err := GroupPolicyFor(leaf); err != nil || ok || !got.IsZero() { + t.Fatalf("leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err) + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyDoesNotLeakParentLocalFlags(t *testing.T) { + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + ApplyGroupPolicy(root, GroupPolicy{ + Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling, + }) + parent := &cobra.Command{ + Use: "search", + RunE: func(*cobra.Command, []string) error { return nil }, + } + parent.Flags().String("dimension", "", "parent-only search dimension") + ApplyGroupPolicy(parent, GroupPolicy{ + Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling, + }) + childCalled := false + child := &cobra.Command{ + Use: "enterprise", + RunE: func(*cobra.Command, []string) error { + childCalled = true + return nil + }, + } + parent.AddCommand(child) + root.AddCommand(parent) + root.SetArgs([]string{"search", "--dimension", "name", "enterprise"}) + + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "unknown flag: --dimension") { + t.Fatalf("Execute() error = %v, want parent local flag rejected by child", err) + } + if childCalled { + t.Fatal("parent local flag leaked into child command execution") + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyHybridPreservesExecution(t *testing.T) { + called := false + cmd := &cobra.Command{ + Use: "hybrid", + RunE: func(*cobra.Command, []string) error { + called = true + return nil + }, + } + ApplyGroupPolicy(cmd, GroupPolicy{ + Mode: GroupHybrid, + Positionals: PositionalsAllow, + Recovery: RecoveryDisabled, + }) + if err := cmd.RunE(cmd, []string{"business-id"}); err != nil || !called { + t.Fatalf("hybrid RunE was not preserved: called=%v err=%v", called, err) + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyHybridRejectRoutesUnknownArgs(t *testing.T) { + called := false + wrapperFrames := 0 + cmd := &cobra.Command{ + Use: "hybrid", + RunE: func(*cobra.Command, []string) error { + called = true + pcs := make([]uintptr, 32) + frames := runtime.CallersFrames(pcs[:runtime.Callers(0, pcs)]) + for { + frame, more := frames.Next() + if strings.Contains(frame.Function, "corecmd.ApplyGroupPolicy.func") { + wrapperFrames++ + } + if !more { + break + } + } + return nil + }, + } + policy := GroupPolicy{ + Mode: GroupHybrid, + Positionals: PositionalsReject, + Recovery: RecoverySibling, + } + ApplyGroupPolicy(cmd, policy) + // Applying the identical declaration must be a no-op. In particular, it + // must not wrap the already wrapped Hybrid RunE a second time. + ApplyGroupPolicy(cmd, policy) + if err := cmd.RunE(cmd, []string{"typo"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") { + t.Fatalf("hybrid typo error = %v", err) + } + if called { + t.Fatal("unknown positional must not reach hybrid business RunE") + } + if err := cmd.RunE(cmd, nil); err != nil || !called { + t.Fatalf("hybrid empty-args execution called=%v err=%v", called, err) + } + if wrapperFrames != 1 { + t.Fatalf("Hybrid RunE wrapper depth = %d, want exactly 1 after idempotent re-apply", wrapperFrames) + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyDeepRecoveryUsesDescendantPath(t *testing.T) { + root := &cobra.Command{Use: "dws"} + sheet := &cobra.Command{Use: "sheet"} + rangeGroup := &cobra.Command{Use: "range"} + rangeGroup.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) + ApplyGroupPolicy(rangeGroup, GroupPolicy{ + Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling, + }) + sheet.AddCommand( + rangeGroup, + &cobra.Command{Use: "+list-sheets", Run: func(*cobra.Command, []string) {}}, + ) + ApplyGroupPolicy(sheet, GroupPolicy{ + Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep, + }) + root.AddCommand(sheet) + + err := sheet.RunE(sheet, []string{"read"}) + var structured *apperrors.Error + if !errors.As(err, &structured) { + t.Fatalf("deep recovery error = %T %v", err, err) + } + if structured.Reason != string(cmdutil.ResolutionUnknownSubcommand) || + structured.Hint != `Did you mean "dws sheet range read"? (Run 'dws sheet --help' for the full list)` { + t.Fatalf("deep recovery = %#v", structured) + } + if got, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(got, []string{"range read"}) { + t.Fatalf("deep suggestions = %#v", structured.Details["suggestions"]) + } + + err = sheet.RunE(sheet, []string{"+list-sheet"}) + structured = nil + if !errors.As(err, &structured) || structured.Reason != string(cmdutil.ResolutionUnknownShortcut) { + t.Fatalf("direct shortcut recovery = %#v, err=%v", structured, err) + } + if !slices.Equal(structured.Actions, []string{ + "Run 'dws sheet --help' for the full list", + "Run 'dws shortcut list --service sheet --format json'", + }) { + t.Fatalf("direct shortcut actions = %#v", structured.Actions) + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyRejectWithoutRecoveryUsesCobraArgs(t *testing.T) { + cmd := &cobra.Command{Use: "parent"} + ApplyGroupPolicy(cmd, GroupPolicy{ + Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled, + }) + if err := cmd.Args(cmd, []string{"extra"}); err == nil { + t.Fatal("RecoveryDisabled must leave rejected positionals to Cobra") + } +} + +func TestCrossPlatformCoverageApplyGroupPolicyFailsClosed(t *testing.T) { + mustPanic := func(name, needle string, fn func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + defer func() { + got := recover() + if got == nil || !strings.Contains(got.(string), needle) { + t.Fatalf("panic = %v, want %q", got, needle) + } + }() + fn() + }) + } + mustPanic("nil command", "nil command", func() { ApplyGroupPolicy(nil, GroupPolicy{}) }) + mustPanic("zero policy", "zero GroupPolicy", func() { ApplyGroupPolicy(&cobra.Command{Use: "leaf"}, GroupPolicy{}) }) + mustPanic("hybrid must run", "must declare RunE", func() { + ApplyGroupPolicy(&cobra.Command{Use: "hybrid"}, GroupPolicy{ + Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling, + }) + }) + mustPanic("conflicting redeclaration", "redeclares GroupPolicy", func() { + cmd := &cobra.Command{Use: "parent"} + ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}) + ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled}) + }) +} + +func TestCrossPlatformCoverageGroupPolicyForRejectsMalformedPrivateMetadata(t *testing.T) { + cmd := &cobra.Command{ + Use: "broken", + Annotations: map[string]string{ + groupPolicyAnnotation: "hybrid|reject", + }, + } + if _, ok, err := GroupPolicyFor(cmd); err == nil || ok || !strings.Contains(err.Error(), "malformed") { + t.Fatalf("GroupPolicyFor malformed = ok %v, err %v", ok, err) + } +} diff --git a/internal/helpers/aisearch.go b/internal/helpers/aisearch.go index b83327f3..c414ec76 100644 --- a/internal/helpers/aisearch.go +++ b/internal/helpers/aisearch.go @@ -210,7 +210,7 @@ func newAisearchCommand() *cobra.Command { return groupRunE(cmd, args) }, } - markGroup(root) + newHybridGroupCommand(root) // root 和 person 各自定义同一组本地 flag,这样: // - dws aisearch --query xxx ← root 自己能解析 diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index dfa3e556..add275ce 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -3721,7 +3721,7 @@ fieldId 必须是 primaryDoc 类型的字段。`, return callAitableTool("get_views", toolArgs) }, } - markGroup(viewGetCmd) + newHybridGroupCommand(viewGetCmd) // ─── view get 子命令:按属性投影 view 响应 ────────────── // card/timebar/aggregate 需要 viewType 校验;filter/sort/group/visible-fields/field-widths 不需要。 @@ -4115,7 +4115,7 @@ fieldWidths 仅支持 Grid 视图。 return callAitableTool("update_view", toolArgs) }, } - markGroup(viewUpdateCmd) + newHybridGroupCommand(viewUpdateCmd) // ─── view update 子命令:按属性局部更新 ──────────────────── diff --git a/internal/helpers/calendar.go b/internal/helpers/calendar.go index 695b644a..faf250fc 100644 --- a/internal/helpers/calendar.go +++ b/internal/helpers/calendar.go @@ -2,17 +2,12 @@ package helpers import ( "encoding/json" - "fmt" - "os" "sort" "strconv" "strings" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -33,163 +28,6 @@ func calendarInfoHintSubCmd(use, suggestion string) *cobra.Command { return c } -// installUnknownVerbFallback makes `group` emit a consistent warning-style -// "Did you mean" hint whenever the caller types an unknown subcommand under -// that group, regardless of whether extra flags follow. This is a blanket -// safety net that covers every verb we never thought to pre-register via -// calendarInfoHintSubCmd (e.g. `dws calendar room query --min-duration 30`). -// -// Two Cobra knobs make this work together: -// - FParseErrWhitelist.UnknownFlags=true stops pflag from aborting with -// "unknown flag: --xxx" before RunE ever runs. -// - Args=cobra.ArbitraryArgs lets Cobra pass the bad verb through as the -// first positional arg instead of rejecting it. -// -// If the user types a *known* subcommand, Cobra still dispatches to that -// child's RunE as usual; this fallback only fires when resolution stops at -// `group` with leftover args. -func installUnknownVerbFallback(group *cobra.Command) { - cmdutil.MarkGroup(group) - group.FParseErrWhitelist.UnknownFlags = true - group.Args = cobra.ArbitraryArgs - - // Override HelpFunc so that ` --help` also shows - // the "unknown subcommand" error instead of silently printing help. - // Cobra intercepts --help before RunE, so without this the fallback - // would never fire when --help is present. - origHelp := group.HelpFunc() - group.SetHelpFunc(func(cmd *cobra.Command, args []string) { - if cmd == group { - // HelpFunc receives os.Args[1:] (full arg slice without binary). - // Strip tokens matching the resolved command path to get actual - // leftover args that should be checked for unknown verbs. - depth := len(strings.Fields(cmd.CommandPath())) - 1 - leftover := stripCommandPrefix(args, depth) - if bad := findUnknownVerb(cmd, leftover); bad != "" { - printUnknownSubcmdError(cmd, bad) - return - } - origHelp(cmd, args) - return - } - // For non-group commands, render base help then apply the safety - // annotation. Recursion safety hinges on NOT calling - // cmd.Root().HelpFunc(): in test trees calendar IS the root, so that - // would re-enter this wrapper. origHelp was captured before any - // wrapping and is the plain cobra renderer. - origHelp(cmd, args) - cli.RenderSafetyAnnotation(cmd) - }) - - prev := group.RunE - group.RunE = func(cmd *cobra.Command, args []string) error { - // Unknown flags whitelisted by pflag may leak into args. Pick the first - // non-flag token as the offending verb. - if bad := findUnknownVerb(cmd, args); bad != "" { - return groupRunE(cmd, []string{bad}) - } - // No unknown verb found. Since FParseErrWhitelist.UnknownFlags silently - // swallows bad flags, scan the original os.Args for flags unregistered - // on this command and report them explicitly. - if flag := findUnknownFlag(cmd); flag != "" { - fmt.Fprintf(os.Stderr, "Error: unknown flag: %s\n", flag) - fmt.Fprintf(os.Stderr, " hint: Run '%s --help' to see available options\n", cmd.CommandPath()) - return nil - } - if prev != nil { - return prev(cmd, args) - } - return cmd.Help() - } -} - -// findUnknownVerb returns the first positional arg that is not a registered -// subcommand (or alias) of cmd. Returns "" if all args are flags or known. -func findUnknownVerb(cmd *cobra.Command, args []string) string { - for _, a := range args { - if strings.HasPrefix(a, "-") { - continue - } - isKnown := false - for _, c := range cmd.Commands() { - if c.Name() == a { - isKnown = true - break - } - for _, alias := range c.Aliases { - if alias == a { - isKnown = true - break - } - } - if isKnown { - break - } - } - if !isKnown { - return a - } - } - return "" -} - -// printUnknownSubcmdError renders the shared bounded diagnostic for Cobra's -// HelpFunc path, which cannot return the structured error to its caller. -func printUnknownSubcmdError(cmd *cobra.Command, bad string) { - _ = apperrors.PrintHuman(cmd.ErrOrStderr(), groupRunE(cmd, []string{bad})) -} - -// stripCommandPrefix strips the first `depth` non-flag tokens from args. -// This is needed because Cobra's HelpFunc receives os.Args[1:] (the full arg -// slice without the binary name), including the resolved command path tokens. -// depth should be len(strings.Fields(cmd.CommandPath())) - 1. -func stripCommandPrefix(args []string, depth int) []string { - skipped := 0 - for i, a := range args { - if skipped >= depth { - return args[i:] - } - if !strings.HasPrefix(a, "-") { - skipped++ - } - } - return nil -} - -// findUnknownFlag scans os.Args for flags that are not registered on cmd. -// Returns the first offending flag token (e.g. "--today") or "". -func findUnknownFlag(cmd *cobra.Command) string { - depth := len(strings.Fields(cmd.CommandPath())) - 1 - leftover := stripCommandPrefix(os.Args[1:], depth) - for i := 0; i < len(leftover); i++ { - a := leftover[i] - if a == "--" { - break - } - if strings.HasPrefix(a, "--") { - name := a[2:] - if eqIdx := strings.Index(name, "="); eqIdx >= 0 { - name = name[:eqIdx] - } - if name == "help" { - continue - } - if cmd.Flags().Lookup(name) == nil { - return a - } - } else if strings.HasPrefix(a, "-") && a != "-" { - ch := a[1:2] - if ch == "h" { - continue - } - if cmd.Flags().ShorthandLookup(ch) == nil { - return a - } - } - } - return "" -} - func newCalendarCommand() *cobra.Command { // Product-level Agent routing Decl (migrated from selection/calendar.json // products.calendar). Catalog assembly stamps provenance contract_final. @@ -2638,12 +2476,6 @@ func newCalendarCommand() *cobra.Command { root.AddCommand(eventCmd, participantCmd, roomCmd, busyCmd, attachmentCmd, bookCmd, aclCmd) - // Install the unknown-verb fallback on every group command. This covers - // arbitrary typos like `dws calendar room query --min-duration 30` that - // the per-verb calendarInfoHintSubCmd registrations below can't anticipate. - for _, g := range []*cobra.Command{root, eventCmd, participantCmd, roomCmd, busyCmd, attachmentCmd, bookCmd, aclCmd} { - installUnknownVerbFallback(g) - } // Hint subcommands must swallow any extra flags/args the caller passes, // otherwise `dws calendar list` prints the nice "ambiguous command" hint // but `dws calendar list --start ...` fails earlier with cobra's diff --git a/internal/helpers/calendar_command_coverage_more_test.go b/internal/helpers/calendar_command_coverage_more_test.go index 98e44817..d38a6310 100644 --- a/internal/helpers/calendar_command_coverage_more_test.go +++ b/internal/helpers/calendar_command_coverage_more_test.go @@ -1,9 +1,10 @@ package helpers import ( - "os" + "errors" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -46,16 +47,13 @@ func TestCrossPlatformCoverageCalendarOptionalFlagsRemainingCoverage(t *testing. func TestCrossPlatformCoverageCalendarUnknownFlagAndSuggestionRemainingCoverage(t *testing.T) { root := &cobra.Command{Use: "calendar"} - group := &cobra.Command{Use: "room"} + group := newGroupCommand(&cobra.Command{Use: "room"}) group.SuggestionsMinimumDistance = 3 group.AddCommand(&cobra.Command{Use: "search", SuggestFor: []string{"serach"}, Run: func(*cobra.Command, []string) {}}) root.AddCommand(group) - installUnknownVerbFallback(group) - oldArgs := os.Args - os.Args = []string{"dws", "calendar", "room", "--unknown"} - t.Cleanup(func() { os.Args = oldArgs }) - if err := group.RunE(group, nil); err != nil { - t.Fatalf("unknown flag fallback: %v", err) + err := group.RunE(group, []string{"serach"}) + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" { + t.Fatalf("typed suggestion fallback: %#v", err) } - printUnknownSubcmdError(group, "serach") } diff --git a/internal/helpers/calendar_coverage_more_test.go b/internal/helpers/calendar_coverage_more_test.go index 1598ec4b..f167df4e 100644 --- a/internal/helpers/calendar_coverage_more_test.go +++ b/internal/helpers/calendar_coverage_more_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -175,44 +176,22 @@ func TestCrossPlatformCoverageBuildRecurrenceCoverage(t *testing.T) { func TestCrossPlatformCoverageCalendarUnknownFallbackCoverage(t *testing.T) { root := &cobra.Command{Use: "calendar"} - group := &cobra.Command{Use: "room", RunE: func(*cobra.Command, []string) error { return errors.New("previous") }} + group := newGroupCommand(&cobra.Command{Use: "room"}) known := &cobra.Command{Use: "search", Aliases: []string{"find"}} hidden := &cobra.Command{Use: "secret", Hidden: true} group.AddCommand(known, hidden) root.AddCommand(group) - installUnknownVerbFallback(group) - _ = group.RunE(group, []string{"unknown"}) - _ = group.RunE(group, []string{"--ignored", "search"}) - _ = group.RunE(group, nil) - group.HelpFunc()(group, []string{"calendar", "room", "unknown"}) - group.HelpFunc()(known, nil) - printUnknownSubcmdError(group, "searhc") - printUnknownSubcmdError(group, "unrelated") + var structured *apperrors.Error + if err := group.RunE(group, []string{"searhc"}); !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" { + t.Fatalf("typed group recovery = %#v", err) + } + if err := group.RunE(group, nil); err != nil { + t.Fatalf("group help = %v", err) + } hint := calendarInfoHintSubCmd("query", "use search") group.AddCommand(hint) if err := hint.RunE(hint, nil); err == nil { t.Fatal("calendar compatibility hint succeeded") } - - oldArgs := os.Args - t.Cleanup(func() { os.Args = oldArgs }) - group.Flags().StringP("known", "k", "", "") - for _, args := range [][]string{ - {"dws", "calendar", "room", "--"}, - {"dws", "calendar", "room", "--help"}, - {"dws", "calendar", "room", "--known=value"}, - {"dws", "calendar", "room", "--unknown=value"}, - {"dws", "calendar", "room", "-h"}, - {"dws", "calendar", "room", "-k", "value"}, - {"dws", "calendar", "room", "-x"}, - } { - os.Args = args - _ = findUnknownFlag(group) - } - nilPrev := &cobra.Command{Use: "empty"} - root.AddCommand(nilPrev) - installUnknownVerbFallback(nilPrev) - os.Args = []string{"dws", "calendar", "empty"} - _ = nilPrev.RunE(nilPrev, nil) } diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index da94bccf..ba7b8751 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -2766,7 +2766,7 @@ func newChatCommand() *cobra.Command { return callMCPTool("get_group_members", toolArgs) }, } - markGroup(chatGroupMembersCmd) + newHybridGroupCommand(chatGroupMembersCmd) chatGroupMembersAddBotCmd := &cobra.Command{ Use: "add-bot", diff --git a/internal/helpers/chat/toolbar.go b/internal/helpers/chat/toolbar.go index 3e9367f4..3cd2e1c9 100644 --- a/internal/helpers/chat/toolbar.go +++ b/internal/helpers/chat/toolbar.go @@ -14,7 +14,7 @@ package chat import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/spf13/cobra" ) @@ -25,7 +25,11 @@ func newChatToolbarCommand() *cobra.Command { Long: "管理会话快捷栏入口:查询、添加、隐藏、排序及自定义入口 CRUD。", RunE: groupRunE, } - cmdutil.MarkGroup(toolbarCmd) + corecmd.ApplyGroupPolicy(toolbarCmd, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) toolbarCmd.DisableAutoGenTag = true toolbarCmd.AddCommand( diff --git a/internal/helpers/chat_media_upload.go b/internal/helpers/chat_media_upload.go index cc132e41..1dfd32c7 100644 --- a/internal/helpers/chat_media_upload.go +++ b/internal/helpers/chat_media_upload.go @@ -33,7 +33,7 @@ func newChatMediaGroup() *cobra.Command { }, } media.AddCommand(newChatMediaUploadCommand()) - markGroup(media) + newHybridGroupCommand(media) return media } diff --git a/internal/helpers/college_contact.go b/internal/helpers/college_contact.go index fab47b62..144f73d8 100644 --- a/internal/helpers/college_contact.go +++ b/internal/helpers/college_contact.go @@ -28,19 +28,19 @@ func newCollegeContactCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "college-contact", Short: "高校通讯录", Long: `钉钉高校通讯录扩展:查询高校组织架构/院系/师生信息等。`, Hidden: true, RunE: groupRunE, - } + }) // ════════════════════════════════════════════════════════════ // dept 子命令组 — 部门管理 // ════════════════════════════════════════════════════════════ - deptCmd := &cobra.Command{Use: "dept", Short: "部门管理", RunE: groupRunE} + deptCmd := newGroupCommand(&cobra.Command{Use: "dept", Short: "部门管理", RunE: groupRunE}) getStandardStructureCmd := &cobra.Command{ Use: "get-standard-structure", @@ -1574,7 +1574,7 @@ func newCollegeContactCommand() *cobra.Command { // employee 子命令组 — 员工管理 // ════════════════════════════════════════════════════════════ - employeeCmd := &cobra.Command{Use: "employee", Short: "员工管理", RunE: groupRunE} + employeeCmd := newGroupCommand(&cobra.Command{Use: "employee", Short: "员工管理", RunE: groupRunE}) getEmployeeDetailCmd := &cobra.Command{ Use: "get-detail", @@ -2654,7 +2654,7 @@ func newCollegeContactCommand() *cobra.Command { // alumni 子命令组 — 校友管理 // ════════════════════════════════════════════════════════════ - alumniCmd := &cobra.Command{Use: "alumni", Short: "校友管理", RunE: groupRunE} + alumniCmd := newGroupCommand(&cobra.Command{Use: "alumni", Short: "校友管理", RunE: groupRunE}) getAlumniDeptTreeCmd := &cobra.Command{ Use: "get-dept-tree", @@ -4120,7 +4120,7 @@ func newCollegeContactCommand() *cobra.Command { // graduate 子命令组 — 毕业年级管理 // ════════════════════════════════════════════════════════════ - graduateCmd := &cobra.Command{Use: "graduate", Short: "毕业年级管理", RunE: groupRunE} + graduateCmd := newGroupCommand(&cobra.Command{Use: "graduate", Short: "毕业年级管理", RunE: groupRunE}) // ── query-graduate-years ────────────────────────────────── queryGraduateYearsCmd := &cobra.Command{ @@ -5346,11 +5346,11 @@ func newCollegeContactCommand() *cobra.Command { commitRestoreCmd.Flags().String("request-no", "", "异步任务请求号") // ── group 子命令组 ──────────────────────────────────────── - groupCmd := &cobra.Command{ + groupCmd := newGroupCommand(&cobra.Command{ Use: "group", Short: "规则管理", RunE: groupRunE, - } + }) // ── query-group-rule ───────────────────────────────────── queryGroupRuleCmd := &cobra.Command{ diff --git a/internal/helpers/conference.go b/internal/helpers/conference.go index 50225b99..9341652c 100644 --- a/internal/helpers/conference.go +++ b/internal/helpers/conference.go @@ -39,7 +39,7 @@ func newConferenceCommand() *cobra.Command { 直接发起会议、邀请入会、会中控制请在钉钉客户端操作;如需预约日程,请改用 dws calendar event create。`, RunE: runUnavailable, } - markGroup(root) + newHybridGroupCommand(root) meetingCmd := newGroupCommand(&cobra.Command{Use: "meeting", Short: "会议管理(已下线)", RunE: groupRunE}) diff --git a/internal/helpers/coverage_pure_more_test.go b/internal/helpers/coverage_pure_more_test.go index 2c526eb6..7b2e70fb 100644 --- a/internal/helpers/coverage_pure_more_test.go +++ b/internal/helpers/coverage_pure_more_test.go @@ -5,11 +5,11 @@ import ( "encoding/json" "fmt" "io" - "os" "testing" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -30,30 +30,6 @@ func TestCrossPlatformCoveragePureScalarAndCommandHelpersCoverage(t *testing.T) _ = isNumericUserID(value) } - root := &cobra.Command{Use: "calendar"} - known := &cobra.Command{Use: "event", Aliases: []string{"e"}} - hidden := &cobra.Command{Use: "hidden", Hidden: true} - root.AddCommand(known, hidden) - for _, args := range [][]string{{"--x"}, {"event"}, {"e"}, {"missing"}} { - _ = findUnknownVerb(root, args) - } - printUnknownSubcmdError(root, "evnt") - for _, depth := range []int{0, 1, 3} { - _ = stripCommandPrefix([]string{"calendar", "event", "--x"}, depth) - } - oldArgs := os.Args - t.Cleanup(func() { os.Args = oldArgs }) - root.Flags().StringP("known", "k", "", "") - for _, args := range [][]string{ - {"dws", "calendar", "--known=x"}, - {"dws", "calendar", "--unknown"}, - {"dws", "calendar", "-z"}, - {"dws", "calendar", "--", "--ignored"}, - } { - os.Args = args - _ = findUnknownFlag(root) - } - for _, event := range []any{ nil, map[string]any{"start": map[string]any{"dateTime": "2026-01-02T03:04:05Z"}}, @@ -284,7 +260,7 @@ func TestCrossPlatformCoverageSmallHandlerAndFormatterCoverage(t *testing.T) { group := &cobra.Command{Use: "range"} group.AddCommand(&cobra.Command{Use: "read"}) parent.AddCommand(group) - _ = deepSuggestSubcommand(parent, "read") - _ = deepSuggestSubcommand(parent, "missing") + _ = cmdutil.SuggestDescendantSubcommands(parent, "read") + _ = cmdutil.SuggestDescendantSubcommands(parent, "missing") _ = time.Now() } diff --git a/internal/helpers/dev.go b/internal/helpers/dev.go index 2c3d6f04..cd7798bc 100644 --- a/internal/helpers/dev.go +++ b/internal/helpers/dev.go @@ -16,7 +16,6 @@ package helpers import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -71,7 +70,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(root) + newGroupCommand(root) doc := &cobra.Command{ Use: "doc", @@ -83,7 +82,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(doc) + newGroupCommand(doc) doc.AddCommand(newDevDocSearchCommand(runner)) root.AddCommand( diff --git a/internal/helpers/devapp.go b/internal/helpers/devapp.go index bad84387..0b0c13e6 100644 --- a/internal/helpers/devapp.go +++ b/internal/helpers/devapp.go @@ -23,7 +23,6 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -140,7 +139,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(root) + newGroupCommand(root) webapp := &cobra.Command{ Use: "webapp", @@ -152,7 +151,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(webapp) + newGroupCommand(webapp) webapp.AddCommand( newDevAppWebappGetCommand(runner), newDevAppWebappConfigCommand(runner), @@ -168,7 +167,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(permission) + newGroupCommand(permission) permission.AddCommand( newDevAppPermissionListCommand(runner), newDevAppPermissionAddCommand(runner), @@ -185,7 +184,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(credentials) + newGroupCommand(credentials) credentials.AddCommand(newDevAppCredentialsGetCommand(runner)) member := &cobra.Command{ @@ -198,7 +197,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(member) + newGroupCommand(member) member.AddCommand( newDevAppMemberListCommand(runner), newDevAppMemberAddCommand(runner), @@ -215,7 +214,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(security) + newGroupCommand(security) security.AddCommand(newDevAppSecurityConfigCommand(runner)) robot := &cobra.Command{ @@ -228,7 +227,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(robot) + newGroupCommand(robot) robot.AddCommand( newDevAppRobotSubmitCommand(runner), newDevAppRobotResultCommand(runner), @@ -248,7 +247,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(version) + newGroupCommand(version) version.AddCommand( newDevAppVersionCreateCommand(runner), newDevAppVersionListCommand(runner), @@ -268,7 +267,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command { return cmd.Help() }, } - cmdutil.MarkGroup(event) + newGroupCommand(event) event.AddCommand( newDevAppEventListCommand(runner), newDevAppEventSubscribeCommand(runner), diff --git a/internal/helpers/devapp_connect.go b/internal/helpers/devapp_connect.go index c4e44962..0d18adff 100644 --- a/internal/helpers/devapp_connect.go +++ b/internal/helpers/devapp_connect.go @@ -587,7 +587,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command { newDevAppRobotConnectRestartCommand(), newDevAppRobotConnectListCommand(runner), ) - markGroup(cmd) + newHybridGroupCommand(cmd) cmd.Flags().String("channel", "auto", "渠道:auto(默认,自动探测)|openclaw|qoder|qoderwork|hermes|workbuddy|claudecode|codebuddy|codex|gemini|opencode|custom(自研/未支持的 AI,配 --agent-cmd)") cmd.Flags().String("agent-cmd", "", "自研/未支持的 AI 工具命令(无头/一次性:问题作为最后一个参数追加,答案打到 stdout);用来接入内置渠道之外的 AI(如网易有道龙虾 LobsterAI);等价于 --channel custom + 设 DWS_AGENT_CMD;env: DWS_AGENT_CMD") // 用 robot-client-* 而非 client-id/client-secret:后者是全局 OAuth 客户端覆盖 diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 25fab91d..a4cf3cb9 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -4260,7 +4260,7 @@ CLI 内部自动完成全部流程: _ = exportCmd.Flags().MarkHidden("file-id") exportCmd.AddCommand(exportGetCmd) - markGroup(exportCmd) + newHybridGroupCommand(exportCmd) // ── import: 文件导入为在线文档(一体化:上传→转换→轮询)────────────── importCmd := &cobra.Command{ @@ -4357,6 +4357,7 @@ CLI 内部自动完成全部流程: }) importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)") importCmd.AddCommand(importGetCmd) + newHybridGroupCommand(importCmd) // ── doc version 子命令组 ── versionCmd := newGroupCommand(&cobra.Command{ diff --git a/internal/helpers/edu_app.go b/internal/helpers/edu_app.go index 0117bd31..35db80fa 100644 --- a/internal/helpers/edu_app.go +++ b/internal/helpers/edu_app.go @@ -32,19 +32,19 @@ func newEduAppCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "edu-app", Short: "家校应用", Long: `钉钉家校应用:AI消息总结、家校任务查询、成绩单管理、通知确认、奖状颁发、作业管理等。`, Hidden: true, RunE: groupRunE, - } + }) // ════════════════════════════════════════════════════════════ // message 子命令组 — AI 消息 // ════════════════════════════════════════════════════════════ - messageCmd := &cobra.Command{Use: "message", Short: "AI 消息管理", RunE: groupRunE} + messageCmd := newGroupCommand(&cobra.Command{Use: "message", Short: "AI 消息管理", RunE: groupRunE}) messageSummaryListCmd := &cobra.Command{ Use: "summary-list", @@ -128,7 +128,7 @@ func newEduAppCommand() *cobra.Command { // task 子命令组 — 家校任务 // ════════════════════════════════════════════════════════════ - taskCmd := &cobra.Command{Use: "task", Short: "家校任务管理", RunE: groupRunE} + taskCmd := newGroupCommand(&cobra.Command{Use: "task", Short: "家校任务管理", RunE: groupRunE}) taskPublishListCmd := &cobra.Command{ Use: "publish-list", @@ -339,7 +339,7 @@ func newEduAppCommand() *cobra.Command { // report 子命令组 — 成绩单 // ════════════════════════════════════════════════════════════ - reportCmd := &cobra.Command{Use: "report", Short: "成绩单管理", RunE: groupRunE} + reportCmd := newGroupCommand(&cobra.Command{Use: "report", Short: "成绩单管理", RunE: groupRunE}) reportGetCmd := &cobra.Command{ Use: "get", @@ -673,7 +673,7 @@ func newEduAppCommand() *cobra.Command { // notice 子命令组 — 通知 // ════════════════════════════════════════════════════════════ - noticeCmd := &cobra.Command{Use: "notice", Short: "通知管理", RunE: groupRunE} + noticeCmd := newGroupCommand(&cobra.Command{Use: "notice", Short: "通知管理", RunE: groupRunE}) noticeConfirmCmd := &cobra.Command{ Use: "confirm", @@ -1252,7 +1252,7 @@ func newEduAppCommand() *cobra.Command { // circle 子命令组 — 班级圈 // ════════════════════════════════════════════════════════════ - circleCmd := &cobra.Command{Use: "circle", Short: "班级圈管理", RunE: groupRunE} + circleCmd := newGroupCommand(&cobra.Command{Use: "circle", Short: "班级圈管理", RunE: groupRunE}) circlePostsCmd := &cobra.Command{ Use: "posts", @@ -1326,7 +1326,7 @@ func newEduAppCommand() *cobra.Command { // card 子命令组 — 打卡 // ════════════════════════════════════════════════════════════ - cardCmd := &cobra.Command{Use: "card", Short: "打卡管理", RunE: groupRunE} + cardCmd := newGroupCommand(&cobra.Command{Use: "card", Short: "打卡管理", RunE: groupRunE}) cardUpdateCmd := &cobra.Command{ Use: "update", @@ -1698,7 +1698,7 @@ func newEduAppCommand() *cobra.Command { // diploma 子命令组 — 奖状 // ════════════════════════════════════════════════════════════ - diplomaCmd := &cobra.Command{Use: "diploma", Short: "奖状管理", RunE: groupRunE} + diplomaCmd := newGroupCommand(&cobra.Command{Use: "diploma", Short: "奖状管理", RunE: groupRunE}) diplomaCreateCmd := &cobra.Command{ Use: "create", @@ -2290,7 +2290,7 @@ func newEduAppCommand() *cobra.Command { // homework 子命令组 — 作业 // ════════════════════════════════════════════════════════════ - homeworkCmd := &cobra.Command{Use: "homework", Short: "作业管理", RunE: groupRunE} + homeworkCmd := newGroupCommand(&cobra.Command{Use: "homework", Short: "作业管理", RunE: groupRunE}) homeworkCreateCmd := &cobra.Command{ Use: "create", diff --git a/internal/helpers/edu_contact.go b/internal/helpers/edu_contact.go index 5548beee..3679030b 100644 --- a/internal/helpers/edu_contact.go +++ b/internal/helpers/edu_contact.go @@ -32,19 +32,19 @@ func newEduContactCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "edu-contact", Short: "家校通讯录", Long: `钉钉家校通讯录扩展:查询学校组织/班级/家庭关系/教师信息等。`, Hidden: true, RunE: groupRunE, - } + }) // ════════════════════════════════════════════════════════════ // school 子命令组 — 学校/组织级查询 // ════════════════════════════════════════════════════════════ - schoolCmd := &cobra.Command{Use: "school", Short: "学校/组织管理", RunE: groupRunE} + schoolCmd := newGroupCommand(&cobra.Command{Use: "school", Short: "学校/组织管理", RunE: groupRunE}) schoolRolesCmd := &cobra.Command{ Use: "roles", @@ -315,7 +315,7 @@ func newEduContactCommand() *cobra.Command { // class 子命令组 — 班级级查询 // ════════════════════════════════════════════════════════════ - classCmd := &cobra.Command{Use: "class", Short: "班级管理", RunE: groupRunE} + classCmd := newGroupCommand(&cobra.Command{Use: "class", Short: "班级管理", RunE: groupRunE}) classDetailCmd := &cobra.Command{ Use: "detail", @@ -1632,7 +1632,7 @@ func newEduContactCommand() *cobra.Command { // family 子命令组 — 家庭关系查询 // ════════════════════════════════════════════════════════════ - familyCmd := &cobra.Command{Use: "family", Short: "家庭关系查询", RunE: groupRunE} + familyCmd := newGroupCommand(&cobra.Command{Use: "family", Short: "家庭关系查询", RunE: groupRunE}) familyChildrenCmd := &cobra.Command{ Use: "children", @@ -1720,7 +1720,7 @@ func newEduContactCommand() *cobra.Command { // teacher 子命令组 — 教师查询 // ════════════════════════════════════════════════════════════ - teacherCmd := &cobra.Command{Use: "teacher", Short: "教师管理", RunE: groupRunE} + teacherCmd := newGroupCommand(&cobra.Command{Use: "teacher", Short: "教师管理", RunE: groupRunE}) teacherClassesCmd := &cobra.Command{ Use: "classes", diff --git a/internal/helpers/edu_family_group.go b/internal/helpers/edu_family_group.go index e655f5f9..c5df7944 100644 --- a/internal/helpers/edu_family_group.go +++ b/internal/helpers/edu_family_group.go @@ -33,19 +33,19 @@ func newEduFamilyGroupCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "edu-familygroup", Short: "家庭群", Long: `钉钉家庭群管理:家庭群查询/创建、孩子管理、家长邀请、学生应用权限控制等。`, Hidden: true, RunE: groupRunE, - } + }) // ════════════════════════════════════════════════════════════ // group 子命令组 — 家庭群读操作 // ════════════════════════════════════════════════════════════ - groupCmd := &cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE} + groupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "家庭群查询", RunE: groupRunE}) groupCheckExistsCmd := &cobra.Command{ Use: "check-exists", @@ -158,7 +158,7 @@ func newEduFamilyGroupCommand() *cobra.Command { // manage 子命令组 — 家庭群写操作 // ════════════════════════════════════════════════════════════ - manageCmd := &cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE} + manageCmd := newGroupCommand(&cobra.Command{Use: "manage", Short: "家庭群管理", RunE: groupRunE}) manageCreateCmd := &cobra.Command{ Use: "create", diff --git a/internal/helpers/edu_group.go b/internal/helpers/edu_group.go index 93d84317..f7c6f864 100644 --- a/internal/helpers/edu_group.go +++ b/internal/helpers/edu_group.go @@ -31,19 +31,19 @@ func newEduGroupCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newGroupCommand(&cobra.Command{ Use: "edu-group", Short: "家校群", Long: `钉钉家校群管理:师生群查询/创建/解散、班级群会话信息查询、批量操作等。`, Hidden: true, RunE: groupRunE, - } + }) // ════════════════════════════════════════════════════════════ // student-group 子命令组 — 师生群管理 // ════════════════════════════════════════════════════════════ - studentGroupCmd := &cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE} + studentGroupCmd := newGroupCommand(&cobra.Command{Use: "student-group", Short: "师生群管理", RunE: groupRunE}) studentGroupInfoCmd := &cobra.Command{ Use: "info", @@ -395,7 +395,7 @@ func newEduGroupCommand() *cobra.Command { // class-group 子命令组 — 班级群(家校群)会话管理 // ════════════════════════════════════════════════════════════ - classGroupCmd := &cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE} + classGroupCmd := newGroupCommand(&cobra.Command{Use: "class-group", Short: "班级群会话管理", RunE: groupRunE}) classGroupConversationIDCmd := &cobra.Command{ Use: "conversation-id", @@ -606,7 +606,7 @@ func newEduGroupCommand() *cobra.Command { // batch 子命令组 — 批量操作 // ════════════════════════════════════════════════════════════ - batchCmd := &cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE} + batchCmd := newGroupCommand(&cobra.Command{Use: "batch", Short: "批量操作", RunE: groupRunE}) batchCheckClassGroupCmd := &cobra.Command{ Use: "check-student-group", diff --git a/internal/helpers/group_command_declaration_test.go b/internal/helpers/group_command_declaration_test.go deleted file mode 100644 index c0349f58..00000000 --- a/internal/helpers/group_command_declaration_test.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 - -package helpers - -import ( - "go/ast" - "go/parser" - "go/token" - "os" - "sort" - "strings" - "testing" -) - -func TestCrossPlatformCoverageGroupRunEDeclarationsUseExplicitGroupConstructor(t *testing.T) { - entries, err := os.ReadDir(".") - if err != nil { - t.Fatalf("read helpers directory: %v", err) - } - - fset := token.NewFileSet() - var violations []string - for _, entry := range entries { - name := entry.Name() - if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { - continue - } - file, parseErr := parser.ParseFile(fset, name, nil, parser.ParseComments) - if parseErr != nil { - t.Fatalf("parse %s: %v", name, parseErr) - } - // 生成文件必须通过其生成器维护,不能由机械迁移直接改写。 - if ast.IsGenerated(file) { - continue - } - - wrapped := explicitGroupCommandLiterals(file) - ast.Inspect(file, func(node ast.Node) bool { - literal, ok := node.(*ast.CompositeLit) - if !ok || !declaresGroupRunE(literal) { - return true - } - if _, ok := wrapped[literal]; !ok { - position := fset.Position(literal.Pos()) - violations = append(violations, position.String()) - } - return true - }) - } - - if len(violations) == 0 { - return - } - sort.Strings(violations) - shown := violations - if len(shown) > 10 { - shown = shown[:10] - } - t.Fatalf("%d groupRunE declarations bypass newGroupCommand; first %d:\n%s", - len(violations), len(shown), strings.Join(shown, "\n")) -} - -func explicitGroupCommandLiterals(file *ast.File) map[*ast.CompositeLit]struct{} { - wrapped := make(map[*ast.CompositeLit]struct{}) - ast.Inspect(file, func(node ast.Node) bool { - call, ok := node.(*ast.CallExpr) - if !ok || len(call.Args) != 1 { - return true - } - callee, ok := call.Fun.(*ast.Ident) - if !ok || callee.Name != "newGroupCommand" { - return true - } - argument, ok := call.Args[0].(*ast.UnaryExpr) - if !ok || argument.Op != token.AND { - return true - } - literal, ok := argument.X.(*ast.CompositeLit) - if ok { - wrapped[literal] = struct{}{} - } - return true - }) - return wrapped -} - -func declaresGroupRunE(literal *ast.CompositeLit) bool { - for _, element := range literal.Elts { - field, ok := element.(*ast.KeyValueExpr) - if !ok { - continue - } - key, keyOK := field.Key.(*ast.Ident) - value, valueOK := field.Value.(*ast.Ident) - if keyOK && valueOK && key.Name == "RunE" && value.Name == "groupRunE" { - return true - } - } - return false -} diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index 5afa7a2b..fbd8208b 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -15,20 +15,19 @@ import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) // Re-export shared command helpers as package-level aliases so existing product // files continue to compile with their current (unexported) call sites. This -// avoids a mass-rename while keeping command-error classification in pipeline -// and the remaining reusable utilities in cmdutil. +// avoids a mass-rename while keeping reusable command-resolution and flag +// utilities in cmdutil. var ( - groupRunE = pipeline.GroupRunE - hintSubCmd = pipeline.HintSubCmd - markGroup = cmdutil.MarkGroup + groupRunE = cmdutil.GroupRunE + hintSubCmd = cmdutil.HintSubCmd mustGetFlag = cmdutil.MustGetFlag flagOrFallback = cmdutil.FlagOrFallback mustFlagOrFallback = cmdutil.MustFlagOrFallback @@ -40,11 +39,37 @@ var ( helperAfter = time.After ) -// newGroupCommand declares a navigation-only command container. The explicit -// identity lets pre-parse command recovery take precedence over child flags -// without inferring group semantics from the shape of the Cobra tree. +// newGroupCommand declares the ordinary navigation policy used by helper +// command containers. The unified framework compiles this declaration into +// Cobra behavior and command-resolution metadata. func newGroupCommand(command *cobra.Command) *cobra.Command { - markGroup(command) + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + return command +} + +// newDeepGroupCommand declares a navigation container whose typo recovery may +// teach exact descendant paths (for example sheet read -> sheet range read). +func newDeepGroupCommand(command *cobra.Command) *cobra.Command { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoveryDeep, + }) + return command +} + +// newHybridGroupCommand declares a business command that also owns children. +// Its existing RunE remains the command's default action. +func newHybridGroupCommand(command *cobra.Command) *cobra.Command { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) return command } diff --git a/internal/helpers/interfaces.go b/internal/helpers/interfaces.go index 19862dd2..57881082 100644 --- a/internal/helpers/interfaces.go +++ b/internal/helpers/interfaces.go @@ -21,7 +21,6 @@ import ( "sync" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -74,10 +73,6 @@ func buildCommands(factories []Factory, runner executor.Runner) []*cobra.Command for _, factory := range factories { handler := factory() command := handler.Command(runner) - // A public helper Factory owns one product root. Product roots are - // positional-free command containers even when they also provide a - // flag-driven default action (for example aisearch). - cmdutil.MarkGroup(command) out = append(out, command) } sort.Slice(out, func(i, j int) bool { diff --git a/internal/helpers/report.go b/internal/helpers/report.go index f0282b1a..d88168ef 100644 --- a/internal/helpers/report.go +++ b/internal/helpers/report.go @@ -386,7 +386,7 @@ func newReportCommand() *cobra.Command { addReportListFlags(inboxListCmd) inboxCmd.AddCommand(inboxListCmd) - markGroup(inboxCmd) + newHybridGroupCommand(inboxCmd) // === outbox subtree(我发出的日报)=== outboxCmd := newGroupCommand(&cobra.Command{Use: "outbox", Short: "发件箱(我发出的日报)", RunE: groupRunE}) diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index 67865188..fb0e9366 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -2,12 +2,9 @@ package helpers import ( "fmt" - "sort" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -41,7 +38,7 @@ func newSheetCommand() *cobra.Command { }, }, }) - root := &cobra.Command{ + root := newDeepGroupCommand(&cobra.Command{ Use: "sheet", Short: "钉钉表格管理", Long: `管理钉钉在线电子表格:创建表格、工作表管理、数据读写、单元格搜索、查找替换、单元格合并与取消合并、行列插入删除移动追加与属性更新、附件上传、浮动图片管理、筛选视图管理、下拉列表管理。 @@ -122,7 +119,7 @@ func newSheetCommand() *cobra.Command { dws sheet template list 获取表格模板列表 dws sheet template search 搜索表格模板 dws sheet template apply 应用表格模板创建新表格文档`, - } + }) // ── Build commands via factory functions ────────────────────────── workbookCmds := newWorkbookCmds() @@ -299,15 +296,6 @@ func newSheetCommand() *cobra.Command { attachSheetConfirmationGuard(root, guard.path, guard.operation, guard.targetHint) } - // Guards for grouped parent commands - attachUnknownSubcommandGuard(root) - attachUnknownSubcommandGuard(rangeCmd) - attachUnknownSubcommandGuard(filterCmd) - attachUnknownSubcommandGuard(filterViewCmd) - attachUnknownSubcommandGuard(condFormatCmd) - attachUnknownSubcommandGuard(chartCmd) - attachUnknownSubcommandGuard(pivotTableCmd) - return root } @@ -318,79 +306,3 @@ func attachSheetConfirmationGuard(root *cobra.Command, path, operation, targetHi } protectSheetMutationCommand(command, operation, targetHint) } - -// attachUnknownSubcommandGuard 为分组型命令挂上拼错子命令时的 did-you-mean 提示。 -// -// 背景:cobra 对父命令的 Args 校验发生在 ParseFlags 之后,而 pflag 默认把未知 flag -// 当作硬错误抛出。于是 `dws sheet read --sheet-id X` 会先报 `unknown flag: --sheet-id`, -// 真正的根因(read 不是 sheet 的直接子命令)被彻底掩盖;同时 `dws sheet reead` 会被 -// 当成位置参数静默吞掉、打印 help 后 exit=0,AI Agent 无法察觉命令执行失败。 -// -// 本函数通过三件套让分组命令在"没匹配到子命令"时给出明确的错误与建议: -// 1. FParseErrWhitelist.UnknownFlags=true —— pflag 不再因未知 flag 中断, -// 未知 flag 连同其值一起被静默消化; -// 2. Args=ArbitraryArgs —— 允许把剩余位置参数交给 RunE 处理; -// 3. RunE —— 取 args[0] 作为拼错的子命令名,先在后代命令里查找完全同名的叶子 -// (能把 `sheet read` 精准引导到 `sheet range read`),找不到再退回共享的 -// 有界同级编辑距离建议;最终返回 error 以保证 exit!=0。 -// -// 仅挂在分组型父命令(sheet/range/filter-view)上,不会影响已在 cobra Find 阶段 -// 精确匹配到的合法叶子命令。 -func attachUnknownSubcommandGuard(cmd *cobra.Command) { - cmd.Args = cobra.ArbitraryArgs - cmd.FParseErrWhitelist = cobra.FParseErrWhitelist{UnknownFlags: true} - cmd.SilenceUsage = true - // cobra 仅在 root 自动把 SuggestionsMinimumDistance 兑成 2,子命令默认为 0, - // 会导致 `sheet range reead` 这样的同级近似拼写无法触发内置建议。 - if cmd.SuggestionsMinimumDistance <= 0 { - cmd.SuggestionsMinimumDistance = 2 - } - cmd.RunE = func(c *cobra.Command, args []string) error { - if len(args) == 0 { - return c.Help() - } - name := args[0] - suggestions := deepSuggestSubcommand(c, name) - if len(suggestions) == 0 { - suggestions = cmdutil.SuggestSubcommands(c, name) - } else { - sort.Strings(suggestions) - if len(suggestions) > cmdutil.MaxCommandSuggestions { - suggestions = suggestions[:cmdutil.MaxCommandSuggestions] - } - } - action := fmt.Sprintf("Run '%s --help' for the full list", c.CommandPath()) - return apperrors.NewValidation( - fmt.Sprintf("unknown subcommand %q for %q", name, c.CommandPath()), - apperrors.WithReason("unknown_subcommand"), - apperrors.WithHint(cmdutil.FormatSubcommandSuggestionHint(c, suggestions, action)), - apperrors.WithActions(action), - apperrors.WithDetails(map[string]any{ - "input": name, - "suggestions": suggestions, - }), - ) - } -} - -// deepSuggestSubcommand 在所有后代命令里查找与 name 完全同名的可用子命令, -// 返回从 parent 出发的相对路径列表。用于把 `sheet read` 这样的平铺习惯引导到 -// 真实的深路径 `sheet range read`。 -func deepSuggestSubcommand(parent *cobra.Command, name string) []string { - var out []string - var walk func(c *cobra.Command, rel []string) - walk = func(c *cobra.Command, rel []string) { - for _, sub := range c.Commands() { - if !sub.IsAvailableCommand() { - continue - } - next := append(append([]string{}, rel...), sub.Name()) - if sub.Name() == name { - out = append(out, strings.Join(next, " ")) - } - walk(sub, next) - } - } - walk(parent, nil) - return out -} diff --git a/internal/helpers/sheet_chart.go b/internal/helpers/sheet_chart.go index 312e52d2..56958a81 100644 --- a/internal/helpers/sheet_chart.go +++ b/internal/helpers/sheet_chart.go @@ -120,7 +120,7 @@ func validateChartProperties(props map[string]any) error { // ─── Chart subcommands ────────────────────────────────────────────────────── func newChartCmd() *cobra.Command { - chartCmd := &cobra.Command{Use: "chart", Short: "浮动图表管理"} + chartCmd := newDeepGroupCommand(&cobra.Command{Use: "chart", Short: "浮动图表管理"}) // ── chart list ────────────────────────────────────────────────────── chartListCmd := &cobra.Command{ diff --git a/internal/helpers/sheet_cond_format.go b/internal/helpers/sheet_cond_format.go index 21c911fe..5b503b48 100644 --- a/internal/helpers/sheet_cond_format.go +++ b/internal/helpers/sheet_cond_format.go @@ -9,7 +9,7 @@ import ( ) func newCondFormatCmd() *cobra.Command { - condFormatCmd := &cobra.Command{Use: "cond-format", Short: "条件格式管理"} + condFormatCmd := newDeepGroupCommand(&cobra.Command{Use: "cond-format", Short: "条件格式管理"}) condFormatListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/sheet_filter_view.go b/internal/helpers/sheet_filter_view.go index 75428354..25c65251 100644 --- a/internal/helpers/sheet_filter_view.go +++ b/internal/helpers/sheet_filter_view.go @@ -164,7 +164,7 @@ func runFilterViewGetCriteria(cmd *cobra.Command, _ []string) error { // ── filter + filter-view 命令定义 ────────────────────────────────────────────── func newFilterCmd() *cobra.Command { - filterCmd := &cobra.Command{Use: "filter", Short: "全局筛选管理"} + filterCmd := newDeepGroupCommand(&cobra.Command{Use: "filter", Short: "全局筛选管理"}) filterGetCmd := &cobra.Command{ Use: "get", @@ -523,7 +523,7 @@ func newFilterCmd() *cobra.Command { } func newFilterViewCmd() *cobra.Command { - filterViewCmd := &cobra.Command{Use: "filter-view", Short: "筛选视图管理"} + filterViewCmd := newDeepGroupCommand(&cobra.Command{Use: "filter-view", Short: "筛选视图管理"}) filterViewListCmd := &cobra.Command{ Use: "list", diff --git a/internal/helpers/sheet_import.go b/internal/helpers/sheet_import.go index d07c58ac..6df7057e 100644 --- a/internal/helpers/sheet_import.go +++ b/internal/helpers/sheet_import.go @@ -156,6 +156,7 @@ func newSheetImportCmdWithConfig(cfg importFlowConfig) *cobra.Command { }) importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)") importCmd.AddCommand(importCreateCmd, importGetCmd) + newHybridGroupCommand(importCmd) return importCmd } diff --git a/internal/helpers/sheet_pivot_table.go b/internal/helpers/sheet_pivot_table.go index 638bfe91..d588e97f 100644 --- a/internal/helpers/sheet_pivot_table.go +++ b/internal/helpers/sheet_pivot_table.go @@ -134,7 +134,7 @@ func readPivotProperties(raw string, requireValues bool) (map[string]any, error) } func newPivotTableCmd() *cobra.Command { - root := newGroupCommand(&cobra.Command{ + root := newDeepGroupCommand(&cobra.Command{ Use: "pivot-table", Short: "透视表管理", RunE: groupRunE, @@ -370,6 +370,5 @@ func newPivotTableCmd() *cobra.Command { deleteCmd.Flags().String("pivot-table-id", "", "透视表 ID (必填)") root.AddCommand(listCmd, createCmd, updateCmd, deleteCmd) - attachUnknownSubcommandGuard(root) return root } diff --git a/internal/helpers/sheet_range_ops.go b/internal/helpers/sheet_range_ops.go index 1c3a8fb5..02ebafb0 100644 --- a/internal/helpers/sheet_range_ops.go +++ b/internal/helpers/sheet_range_ops.go @@ -12,7 +12,7 @@ import ( // (read/update/clear/sort/fill/copy-to/move-to). // set-style, batch-set-style, and batch-clear are added by newSheetCommand(). func newRangeCmd() *cobra.Command { - rangeCmd := &cobra.Command{Use: "range", Short: "数据区域操作"} + rangeCmd := newDeepGroupCommand(&cobra.Command{Use: "range", Short: "数据区域操作"}) rangeReadCmd := &cobra.Command{ Use: "read", diff --git a/internal/helpers/sheet_template.go b/internal/helpers/sheet_template.go index 71063c1c..c7e2c42c 100644 --- a/internal/helpers/sheet_template.go +++ b/internal/helpers/sheet_template.go @@ -9,7 +9,7 @@ import ( // newSheetTemplateCmd builds the `dws sheet template` command group. func newSheetTemplateCmd() *cobra.Command { - templateCmd := newGroupCommand(&cobra.Command{ + templateCmd := newDeepGroupCommand(&cobra.Command{ Use: "template", Short: "表格模板管理", RunE: groupRunE, @@ -205,7 +205,5 @@ func newSheetTemplateCmd() *cobra.Command { RegisterCrossProductAliases(child) } - attachUnknownSubcommandGuard(templateCmd) - return templateCmd } diff --git a/internal/helpers/small_remaining_coverage_test.go b/internal/helpers/small_remaining_coverage_test.go index d3710af0..05603b6c 100644 --- a/internal/helpers/small_remaining_coverage_test.go +++ b/internal/helpers/small_remaining_coverage_test.go @@ -10,6 +10,7 @@ import ( "time" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -22,8 +23,13 @@ func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T group := &cobra.Command{Use: "range", Run: func(*cobra.Command, []string) {}} group.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) root.AddCommand(group) - attachUnknownSubcommandGuard(root) - err := root.RunE(root, []string{"read"}) + err := cmdutil.NewCommandResolution( + root, + "read", + cmdutil.ResolutionUnknownSubcommand, + cmdutil.SuggestDescendantSubcommands(root, "read"), + "", + ).Err() var structured *apperrors.Error if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, "range read") { t.Fatalf("deep suggestion err=%#v", err) @@ -35,8 +41,13 @@ func TestCrossPlatformCoverageSheetAndMinutesSmallRemainingBranches(t *testing.T group.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) bounded.AddCommand(group) } - attachUnknownSubcommandGuard(bounded) - err = bounded.RunE(bounded, []string{"read"}) + err = cmdutil.NewCommandResolution( + bounded, + "read", + cmdutil.ResolutionUnknownSubcommand, + cmdutil.SuggestDescendantSubcommands(bounded, "read"), + "", + ).Err() if !errors.As(err, &structured) { t.Fatalf("bounded deep suggestion error = %T %v", err, err) } diff --git a/internal/pat/pat.go b/internal/pat/pat.go index 4d32cf88..4e1a6e27 100644 --- a/internal/pat/pat.go +++ b/internal/pat/pat.go @@ -19,9 +19,8 @@ import ( "github.com/spf13/cobra" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" ) @@ -88,9 +87,12 @@ DINGTALK_AGENT(可选,仅供 x-dingtalk-agent 使用): 它不参与 claw-type 派生,也不参与 host-owned PAT 判定。 DWS_CHANNEL 只用于上游 channelCode。`, - RunE: pipeline.GroupRunE, } - cmdutil.MarkGroup(patCmd) + corecmd.ApplyGroupPolicy(patCmd, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) patCmd.AddCommand(newChmodCommand(c)) patCmd.AddCommand(newBrowserPolicyCommand()) diff --git a/internal/pat/pat_register_test.go b/internal/pat/pat_register_test.go index 30396bdd..67389beb 100644 --- a/internal/pat/pat_register_test.go +++ b/internal/pat/pat_register_test.go @@ -6,12 +6,13 @@ package pat import ( "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" "github.com/spf13/cobra" ) func TestCrossPlatformCoverageRegisterCommandsMergesExistingPATShortcutGroup(t *testing.T) { root := &cobra.Command{Use: "dws"} - shortcutGroup := &cobra.Command{Use: "pat", Short: "pat shortcuts"} + shortcutGroup := cobracmd.NewGroupCommand("pat", "pat shortcuts") shortcutGroup.AddCommand(&cobra.Command{Use: "+fixture"}) root.AddCommand(&cobra.Command{Use: "aaa-other"}) root.AddCommand(shortcutGroup) diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go index 0cd35e4b..384aacfc 100644 --- a/internal/pipeline/cobra.go +++ b/internal/pipeline/cobra.go @@ -32,10 +32,10 @@ import ( // SetArgs so that Cobra's subsequent ExecuteC uses the corrected // values. // -// Explicit +shortcut tokens and annotated group subcommands are validated -// before flag parsing so an unknown command cannot be misreported as an -// unknown flag on its nearest parent. Other unresolved paths remain Cobra's -// responsibility. +// Explicit +shortcut tokens and groups whose typed GroupPolicy enables +// recovery are validated before flag parsing so an unknown command cannot be +// misreported as an unknown flag on its nearest parent. Other unresolved paths +// remain Cobra's responsibility. func RunPreParse(root *cobra.Command, engine *Engine) error { _, err := RunPreParseArgs(root, engine, os.Args[1:]) return err diff --git a/internal/pipeline/command_fallback_test.go b/internal/pipeline/command_fallback_test.go index 9c555807..459bccd6 100644 --- a/internal/pipeline/command_fallback_test.go +++ b/internal/pipeline/command_fallback_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -346,6 +347,21 @@ func commandFallbackPipelineRoot() (*cobra.Command, *string) { group := &cobra.Command{Use: "group"} group.AddCommand(cmdutil.HintSubCmd("search", "use dws chat search")) chat.AddCommand(good, other, search, group) + corecmd.ApplyGroupPolicy(group, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + corecmd.ApplyGroupPolicy(chat, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) root.AddCommand(chat) return root, executed } diff --git a/internal/pipeline/command_resolution.go b/internal/pipeline/command_resolution.go index 1378e78f..99de102e 100644 --- a/internal/pipeline/command_resolution.go +++ b/internal/pipeline/command_resolution.go @@ -4,10 +4,9 @@ package pipeline import ( - "fmt" "strings" - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" ) @@ -17,13 +16,9 @@ import ( // unknown, so a later flag can incorrectly turn an invented command into an // "unknown flag" error on that parent. // -// Only explicit, unambiguous command containers are handled here: -// - the root command, which has no positional contract; -// - a top-level service followed by an explicit +shortcut token; and -// - a command explicitly annotated as a group container. -// -// Other unresolved positionals remain Cobra's responsibility so commands that -// legitimately accept positional arguments are not reclassified. +// Only groups whose typed framework policy rejects positionals and enables +// recovery are handled here. Commands that legitimately accept positionals +// remain Cobra's responsibility, without inference from tree shape or RunE. func validateUnresolvedCommand(target *cobra.Command, remaining []string) error { if target == nil || len(remaining) == 0 { return nil @@ -38,97 +33,23 @@ func validateUnresolvedCommand(target *cobra.Command, remaining []string) error if target == target.Root() && candidate == "help" { return nil } - - if isExplicitShortcutCandidate(target, candidate) { - return unknownShortcutError(target, candidate) + policy, declared, err := corecmd.GroupPolicyFor(target) + if err != nil { + return err } - if target == target.Root() || cmdutil.IsGroup(target) { - return unknownSubcommandError(target, candidate) + if !declared || policy.Positionals != corecmd.PositionalsReject || policy.Recovery == corecmd.RecoveryDisabled { + return nil + } + + reason := cmdutil.ClassifyCommandResolution(target, candidate) + suggestions := cmdutil.SuggestSubcommands(target, candidate) + if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == corecmd.RecoveryDeep { + if deep := cmdutil.SuggestDescendantSubcommands(target, candidate); len(deep) > 0 { + suggestions = deep + } + } + if policy.Recovery == corecmd.RecoverySibling || policy.Recovery == corecmd.RecoveryDeep { + return cmdutil.NewCommandResolution(target, candidate, reason, suggestions, "").Err() } return nil } - -func isExplicitShortcutCandidate(target *cobra.Command, candidate string) bool { - if target == nil || !strings.HasPrefix(candidate, "+") { - return false - } - root := target.Root() - if root == nil || target == root || target.Parent() != root { - return false - } - for _, child := range target.Commands() { - if strings.HasPrefix(child.Name(), "+") { - return true - } - } - return false -} - -func unknownShortcutError(parent *cobra.Command, candidate string) error { - helpAction := fmt.Sprintf("Run '%s --help' for the full list", parent.CommandPath()) - listAction := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name()) - suggestions := cmdutil.SuggestSubcommands(parent, candidate) - return apperrors.NewValidation( - fmt.Sprintf("unknown shortcut %q for %q", candidate, parent.CommandPath()), - apperrors.WithReason("unknown_shortcut"), - apperrors.WithHint(cmdutil.FormatSubcommandSuggestionHint(parent, suggestions, helpAction)), - apperrors.WithActions(helpAction, listAction), - apperrors.WithDetails(commandSuggestionDetails(candidate, suggestions)), - ) -} - -func unknownSubcommandError(parent *cobra.Command, candidate string) error { - action := fmt.Sprintf("Run '%s --help' for the full list", parent.CommandPath()) - suggestions := cmdutil.SuggestSubcommands(parent, candidate) - return apperrors.NewValidation( - fmt.Sprintf("unknown subcommand %q for %q", candidate, parent.CommandPath()), - apperrors.WithReason("unknown_subcommand"), - apperrors.WithHint(cmdutil.FormatSubcommandSuggestionHint(parent, suggestions, action)), - apperrors.WithActions(action), - apperrors.WithDetails(commandSuggestionDetails(candidate, suggestions)), - ) -} - -func commandSuggestionDetails(candidate string, suggestions []string) map[string]any { - return map[string]any{ - "input": candidate, - "suggestions": suggestions, - } -} - -// HintSubCmd creates a hidden compatibility command for a known wrong path. -// It preserves cmdutil's hint-only identity while returning the same typed -// validation contract as fuzzy unknown-subcommand recovery. -func HintSubCmd(use, authoredHint string) *cobra.Command { - command := cmdutil.HintSubCmd(use, authoredHint) - command.RunE = func(cmd *cobra.Command, _ []string) error { - parent := cmd.Parent() - if parent == nil { - parent = cmd.Root() - } - parentPath := parent.CommandPath() - action := fmt.Sprintf("Run '%s --help' for the full list", parentPath) - hint := strings.TrimSpace(authoredHint) - if hint == "" { - hint = action - } else { - hint += " (" + action + ")" - } - return apperrors.NewValidation( - fmt.Sprintf("unknown subcommand %q for %q", cmd.Name(), parentPath), - apperrors.WithReason("unknown_subcommand"), - apperrors.WithHint(hint), - apperrors.WithActions(action), - apperrors.WithDetails(commandSuggestionDetails(cmd.Name(), []string{})), - ) - } - return command -} - -// GroupRunE is the structured group handler used by the DWS command tree. -func GroupRunE(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - return unknownSubcommandError(cmd, strings.TrimSpace(args[0])) - } - return cmd.Help() -} diff --git a/internal/pipeline/command_resolution_test.go b/internal/pipeline/command_resolution_test.go index a8b1fbb7..6a2ec5de 100644 --- a/internal/pipeline/command_resolution_test.go +++ b/internal/pipeline/command_resolution_test.go @@ -5,9 +5,11 @@ package pipeline import ( stderrors "errors" + "slices" "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -21,6 +23,7 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeF &cobra.Command{Use: "+messages-send", Run: func(*cobra.Command, []string) {}}, &cobra.Command{Use: "chat-mesages", Run: func(*cobra.Command, []string) {}}, ) + applyTestNavigationGroup(chat) root.AddCommand(chat) err := validateUnresolvedCommand(chat, []string{"+chat-mesages", "--keyword", "x"}) @@ -38,13 +41,19 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeF if len(structured.AvailableFlags) != 0 { t.Fatalf("AvailableFlags = %#v, want none for command error", structured.AvailableFlags) } + if structured.Details["input"] != "+chat-mesages" { + t.Fatalf("Details.input = %#v", structured.Details["input"]) + } + if suggestions, ok := structured.Details["suggestions"].([]string); !ok || len(suggestions) != 1 || suggestions[0] != "+chat-messages" { + t.Fatalf("Details.suggestions = %#v", structured.Details["suggestions"]) + } } func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitContainers(t *testing.T) { root := &cobra.Command{Use: "dws"} dev := &cobra.Command{Use: "dev"} app := &cobra.Command{Use: "app"} - cmdutil.MarkGroup(app) + applyTestNavigationGroup(app) app.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) dev.AddCommand(app) root.AddCommand(dev) @@ -57,8 +66,14 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitCon if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' for the full list" { t.Fatalf("Actions = %#v", structured.Actions) } - legacy := &cobra.Command{Use: "legacy", RunE: GroupRunE} - cmdutil.MarkGroup(legacy) + if structured.Details["input"] != "search" { + t.Fatalf("Details.input = %#v", structured.Details["input"]) + } + if suggestions, ok := structured.Details["suggestions"].([]string); !ok || len(suggestions) != 0 { + t.Fatalf("Details.suggestions = %#v", structured.Details["suggestions"]) + } + legacy := &cobra.Command{Use: "legacy", RunE: cmdutil.GroupRunE} + applyTestNavigationGroup(legacy) legacy.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) root.AddCommand(legacy) requireCommandResolutionError(t, validateUnresolvedCommand(legacy, []string{"lisst", "--query", "x"}), "unknown_subcommand") @@ -93,6 +108,7 @@ func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAnd root.PersistentFlags().String("format", "table", "") chat := &cobra.Command{Use: "chat"} chat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}}) + applyTestNavigationGroup(chat) root.AddCommand(chat) ctx, err := RunPreParseArgs(root, nil, []string{ @@ -109,12 +125,57 @@ func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAnd validRoot := &cobra.Command{Use: "dws"} validChat := &cobra.Command{Use: "chat"} validChat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}}) + applyTestNavigationGroup(validChat) validRoot.AddCommand(validChat) if ctx, err := RunPreParseArgs(validRoot, nil, []string{"chat", "+chat-messages"}); ctx != nil || err != nil { t.Fatalf("valid shortcut = %#v, %v", ctx, err) } } +func TestCrossPlatformCoverageRunPreParseArgsUsesTypedDeepRecoveryPolicy(t *testing.T) { + root := &cobra.Command{Use: "dws"} + sheet := &cobra.Command{Use: "sheet"} + rangeGroup := &cobra.Command{Use: "range"} + rangeGroup.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}}) + corecmd.ApplyGroupPolicy(rangeGroup, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + sheet.AddCommand( + rangeGroup, + &cobra.Command{Use: "ready", Run: func(*cobra.Command, []string) {}}, + ) + corecmd.ApplyGroupPolicy(sheet, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoveryDeep, + }) + root.AddCommand(sheet) + + ctx, err := RunPreParseArgs(root, nil, []string{"sheet", "read", "--sheet-id", "sheet-1"}) + if ctx == nil || ctx.Command != "dws sheet" { + t.Fatalf("Context = %#v, want unresolved target dws sheet", ctx) + } + structured := requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand)) + if structured.Message != `unknown subcommand "read" for "dws sheet"` { + t.Fatalf("Message = %q", structured.Message) + } + if structured.Hint != `Did you mean "dws sheet range read"? (Run 'dws sheet --help' for the full list)` { + t.Fatalf("Hint = %q", structured.Hint) + } + if !slices.Equal(structured.Actions, []string{"Run 'dws sheet --help' for the full list"}) { + t.Fatalf("Actions = %#v", structured.Actions) + } + if structured.Details["input"] != "read" { + t.Fatalf("Details.input = %#v", structured.Details["input"]) + } + suggestions, ok := structured.Details["suggestions"].([]string) + if !ok || !slices.Equal(suggestions, []string{"range read"}) { + t.Fatalf("Details.suggestions = %#v", structured.Details["suggestions"]) + } +} + func TestCrossPlatformCoverageCommandSuggestionHintIsBoundedAndFallsBack(t *testing.T) { root := &cobra.Command{Use: "dws"} group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100} @@ -141,12 +202,19 @@ func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPat service.AddCommand(group) root.AddCommand(service) - if isExplicitShortcutCandidate(root, "+missing") { + if got := cmdutil.ClassifyCommandResolution(root, "+missing"); got != cmdutil.ResolutionUnknownSubcommand { t.Fatal("root command was treated as a shortcut service") } - if isExplicitShortcutCandidate(group, "+missing") { + if got := cmdutil.ClassifyCommandResolution(group, "+missing"); got != cmdutil.ResolutionUnknownSubcommand { t.Fatal("nested command was treated as a top-level shortcut service") } + service.AddCommand(&cobra.Command{Use: "+known", Aliases: []string{"+alias"}, Run: func(*cobra.Command, []string) {}}) + if got := cmdutil.ClassifyCommandResolution(service, "+missing"); got != cmdutil.ResolutionUnknownShortcut { + t.Fatalf("top-level shortcut typo reason = %q", got) + } + if got := cmdutil.ClassifyCommandResolution(service, "+known"); got != cmdutil.ResolutionUnknownSubcommand { + t.Fatalf("exact shortcut child was reclassified as unresolved shortcut: %q", got) + } if got := cmdutil.SuggestSubcommands(nil, "missing"); got != nil { t.Fatalf("SuggestSubcommands(nil) = %#v", got) } @@ -165,7 +233,7 @@ func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPat func TestCrossPlatformCoverageHintSubCmdReturnsTypedRecovery(t *testing.T) { root := &cobra.Command{Use: "dws"} parent := &cobra.Command{Use: "chat"} - hint := HintSubCmd("send", "use: dws chat message send") + hint := cmdutil.HintSubCmd("send", "use: dws chat message send") root.AddCommand(parent) parent.AddCommand(hint) @@ -186,7 +254,7 @@ func TestCrossPlatformCoverageHintSubCmdReturnsTypedRecovery(t *testing.T) { } func TestCrossPlatformCoverageHintSubCmdAndGroupRunEDefensiveBranches(t *testing.T) { - standaloneHint := HintSubCmd("send", " \t ") + standaloneHint := cmdutil.HintSubCmd("send", " \t ") structured := requireCommandResolutionError(t, standaloneHint.RunE(standaloneHint, nil), "unknown_subcommand") if structured.Hint != "Run 'send --help' for the full list" { t.Fatalf("standalone empty hint = %q", structured.Hint) @@ -196,7 +264,7 @@ func TestCrossPlatformCoverageHintSubCmdAndGroupRunEDefensiveBranches(t *testing group := &cobra.Command{Use: "demo"} group.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}) root.AddCommand(group) - structured = requireCommandResolutionError(t, GroupRunE(group, []string{"lisst"}), "unknown_subcommand") + structured = requireCommandResolutionError(t, cmdutil.GroupRunE(group, []string{"lisst"}), "unknown_subcommand") if structured.Details["input"] != "lisst" { t.Fatalf("GroupRunE() details.input = %#v", structured.Details["input"]) } @@ -207,7 +275,7 @@ func TestCrossPlatformCoverageHintSubCmdAndGroupRunEDefensiveBranches(t *testing var help strings.Builder group.SetOut(&help) - if err := GroupRunE(group, nil); err != nil { + if err := cmdutil.GroupRunE(group, nil); err != nil { t.Fatalf("GroupRunE() help error = %v", err) } if output := help.String(); !strings.Contains(output, "Usage:") || !strings.Contains(output, "list") { @@ -226,3 +294,11 @@ func requireCommandResolutionError(t *testing.T, err error, reason string) *appe } return structured } + +func applyTestNavigationGroup(command *cobra.Command) { + corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) +} diff --git a/internal/shortcut/register.go b/internal/shortcut/register.go index 6073ff5d..7857a8dd 100644 --- a/internal/shortcut/register.go +++ b/internal/shortcut/register.go @@ -16,7 +16,7 @@ package shortcut import ( "sort" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" "github.com/spf13/cobra" ) @@ -69,11 +69,7 @@ func build(shortcuts []Shortcut) []*cobra.Command { for _, s := range shortcuts { parent, ok := byService[s.Service] if !ok { - parent = &cobra.Command{ - Use: s.Service, - Short: s.Service + " shortcuts", - } - cmdutil.MarkGroup(parent) + parent = cobracmd.NewGroupCommand(s.Service, s.Service+" shortcuts") byService[s.Service] = parent order = append(order, s.Service) } diff --git a/internal/shortcut/shortcut_test.go b/internal/shortcut/shortcut_test.go index 9fcb7e3d..d503a308 100644 --- a/internal/shortcut/shortcut_test.go +++ b/internal/shortcut/shortcut_test.go @@ -24,7 +24,6 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -474,8 +473,9 @@ func TestCrossPlatformCoverageBuildGroupsByService(t *testing.T) { t.Errorf("calendar has %d leaves, want 1", got) } for name, command := range byName { - if !cmdutil.IsGroup(command) { - t.Errorf("%s shortcut service parent is not marked as a command group", name) + policy, declared, err := corecmd.GroupPolicyFor(command) + if err != nil || !declared || policy.Mode != corecmd.GroupNavigationOnly { + t.Errorf("%s shortcut service parent policy = %+v, %v, %v", name, policy, declared, err) } } } diff --git a/pkg/cmdutil/command_resolution.go b/pkg/cmdutil/command_resolution.go new file mode 100644 index 00000000..c8000dc9 --- /dev/null +++ b/pkg/cmdutil/command_resolution.go @@ -0,0 +1,222 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cmdutil + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/spf13/cobra" +) + +// ResolutionReason is the stable machine-readable classification for a +// command-token resolution failure. +type ResolutionReason string + +const ( + // ResolutionUnknownSubcommand reports a token that does not resolve to a + // child command of the selected parent. + ResolutionUnknownSubcommand ResolutionReason = "unknown_subcommand" + // ResolutionUnknownShortcut reports an explicit +shortcut token that does + // not resolve under a top-level service. + ResolutionUnknownShortcut ResolutionReason = "unknown_shortcut" +) + +// CommandResolution is the immutable typed projection of a command-token +// resolution failure. Its constructors normalize suggestions once so the +// human hint and machine-readable details cannot diverge. +type CommandResolution struct { + reason ResolutionReason + input string + suggestions []string + message string + hint string + actions []string +} + +// ClassifyCommandResolution returns the stable reason for an unresolved token. +// A leading '+' is shortcut syntax only for a top-level service that actually +// exposes +shortcut children, and only when it does not already name a real +// child or alias. Keeping this classification beside CommandResolution +// prevents PreParse and direct Cobra execution from producing different +// machine contracts for the same input. +func ClassifyCommandResolution(parent *cobra.Command, input string) ResolutionReason { + input = strings.TrimSpace(input) + if strings.HasPrefix(input, "+") && isTopLevelShortcutService(parent) && !hasExactChildCommand(parent, input) { + return ResolutionUnknownShortcut + } + return ResolutionUnknownSubcommand +} + +// NewCommandResolution builds a bounded command-resolution result. Callers +// may pass sibling suggestions from SuggestSubcommands or reviewed deep-path +// candidates; both are normalized to the same three-item contract. +func NewCommandResolution(parent *cobra.Command, input string, reason ResolutionReason, suggestions []string, authoredHint string) CommandResolution { + input = strings.TrimSpace(input) + suggestions = normalizeCommandSuggestions(suggestions) + parentPath := commandResolutionParentPath(parent) + helpAction := fmt.Sprintf("Run '%s --help' for the full list", parentPath) + + resolution := CommandResolution{ + reason: reason, + input: input, + suggestions: suggestions, + actions: []string{helpAction}, + } + + switch reason { + case ResolutionUnknownShortcut: + resolution.message = fmt.Sprintf("unknown shortcut %q for %q", input, parentPath) + if parent != nil { + root := parent.Root() + if root != nil { + resolution.actions = append(resolution.actions, + fmt.Sprintf("Run '%s shortcut list --service %s --format json'", root.Name(), parent.Name())) + } + } + default: + resolution.reason = ResolutionUnknownSubcommand + resolution.message = fmt.Sprintf("unknown subcommand %q for %q", input, parentPath) + } + + authoredHint = strings.TrimSpace(authoredHint) + if authoredHint != "" { + resolution.hint = authoredHint + " (" + helpAction + ")" + } else { + resolution.hint = FormatSubcommandSuggestionHint(parent, suggestions, helpAction) + } + return resolution +} + +// NewInputCommandResolution classifies input and builds its one normalized +// human/machine projection. It is the shared entry point for PreParse and +// direct group execution. +func NewInputCommandResolution(parent *cobra.Command, input string, suggestions []string) CommandResolution { + return NewCommandResolution(parent, input, ClassifyCommandResolution(parent, input), suggestions, "") +} + +// Details returns a fresh machine-readable payload for the resolution. It +// always carries the exact bounded suggestions rendered in Hint. +func (r CommandResolution) Details() map[string]any { + suggestions := make([]string, len(r.suggestions)) + copy(suggestions, r.suggestions) + return map[string]any{ + "input": r.input, + "suggestions": suggestions, + } +} + +// Err projects the resolution through the repository's structured validation +// error contract. +func (r CommandResolution) Err() error { + return apperrors.NewValidation( + r.message, + apperrors.WithReason(string(r.reason)), + apperrors.WithHint(r.hint), + apperrors.WithActions(r.actions...), + apperrors.WithDetails(r.Details()), + ) +} + +// GroupRunE is the reusable handler for navigation-only parent commands. It +// shows help without arguments and otherwise returns the same structured +// resolution contract used by pre-parse validation. +func GroupRunE(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Help() + } + input := strings.TrimSpace(args[0]) + return NewInputCommandResolution(cmd, input, SuggestSubcommands(cmd, input)).Err() +} + +// HintSubCmd creates a hidden compatibility command for a reviewed wrong path. +// It keeps hint-only identity while returning the unified typed resolution. +func HintSubCmd(use, authoredHint string) *cobra.Command { + command := &cobra.Command{ + Use: use, + Hidden: true, + Annotations: map[string]string{hintOnlyCommandAnnotation: "true"}, + } + command.RunE = func(cmd *cobra.Command, _ []string) error { + parent := cmd.Parent() + if parent == nil { + parent = cmd.Root() + } + return NewCommandResolution( + parent, + cmd.Name(), + ResolutionUnknownSubcommand, + []string{}, + authoredHint, + ).Err() + } + return command +} + +func normalizeCommandSuggestions(suggestions []string) []string { + out := make([]string, 0, min(len(suggestions), MaxCommandSuggestions)) + seen := make(map[string]struct{}, min(len(suggestions), MaxCommandSuggestions)) + for _, suggestion := range suggestions { + suggestion = strings.TrimSpace(suggestion) + if suggestion == "" { + continue + } + if _, duplicate := seen[suggestion]; duplicate { + continue + } + seen[suggestion] = struct{}{} + out = append(out, suggestion) + if len(out) == MaxCommandSuggestions { + break + } + } + return out +} + +func commandResolutionParentPath(parent *cobra.Command) string { + if parent == nil { + return "dws" + } + if path := strings.TrimSpace(parent.CommandPath()); path != "" { + return path + } + if name := strings.TrimSpace(parent.Name()); name != "" { + return name + } + return "dws" +} + +func hasExactChildCommand(parent *cobra.Command, candidate string) bool { + if parent == nil { + return false + } + for _, child := range parent.Commands() { + if child.Name() == candidate { + return true + } + for _, alias := range child.Aliases { + if alias == candidate { + return true + } + } + } + return false +} + +func isTopLevelShortcutService(parent *cobra.Command) bool { + if parent == nil { + return false + } + root := parent.Root() + if root == nil || parent == root || parent.Parent() != root { + return false + } + for _, child := range parent.Commands() { + if strings.HasPrefix(child.Name(), "+") { + return true + } + } + return false +} diff --git a/pkg/cmdutil/command_resolution_test.go b/pkg/cmdutil/command_resolution_test.go new file mode 100644 index 00000000..25505eb4 --- /dev/null +++ b/pkg/cmdutil/command_resolution_test.go @@ -0,0 +1,146 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package cmdutil + +import ( + "errors" + "slices" + "strings" + "testing" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageCommandResolutionProjectsExactShortcutContract(t *testing.T) { + root := &cobra.Command{Use: "dws"} + chat := &cobra.Command{Use: "chat"} + root.AddCommand(chat) + + resolution := NewCommandResolution( + chat, + " +chat-mesages ", + ResolutionUnknownShortcut, + []string{"+chat-messages"}, + "", + ) + structured := requireTypedCommandResolution(t, resolution.Err(), ResolutionUnknownShortcut) + + if structured.Message != `unknown shortcut "+chat-mesages" for "dws chat"` { + t.Fatalf("Message = %q", structured.Message) + } + if structured.Hint != `Did you mean "dws chat +chat-messages"? (Run 'dws chat --help' for the full list)` { + t.Fatalf("Hint = %q", structured.Hint) + } + wantActions := []string{ + "Run 'dws chat --help' for the full list", + "Run 'dws shortcut list --service chat --format json'", + } + if !slices.Equal(structured.Actions, wantActions) { + t.Fatalf("Actions = %#v, want %#v", structured.Actions, wantActions) + } + assertResolutionDetails(t, structured, "+chat-mesages", []string{"+chat-messages"}) +} + +func TestCrossPlatformCoverageGroupRunEClassifiesShortcutLikePreParse(t *testing.T) { + root := &cobra.Command{Use: "dws"} + chat := &cobra.Command{Use: "chat"} + chat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(chat) + + structured := requireTypedCommandResolution(t, GroupRunE(chat, []string{"+chat-mesages"}), ResolutionUnknownShortcut) + if structured.Message != `unknown shortcut "+chat-mesages" for "dws chat"` { + t.Fatalf("Message = %q", structured.Message) + } + if structured.Hint != `Did you mean "dws chat +chat-messages"? (Run 'dws chat --help' for the full list)` { + t.Fatalf("Hint = %q", structured.Hint) + } + if !slices.Equal(structured.Actions, []string{ + "Run 'dws chat --help' for the full list", + "Run 'dws shortcut list --service chat --format json'", + }) { + t.Fatalf("Actions = %#v", structured.Actions) + } + assertResolutionDetails(t, structured, "+chat-mesages", []string{"+chat-messages"}) +} + +func TestCrossPlatformCoverageCommandResolutionBoundsOneSuggestionSetForHintAndDetails(t *testing.T) { + root := &cobra.Command{Use: "dws"} + group := &cobra.Command{Use: "demo"} + root.AddCommand(group) + + resolution := NewCommandResolution( + group, + " alphx ", + ResolutionUnknownSubcommand, + []string{" alpha ", "alpha", "beta", "", "gamma", "delta"}, + "", + ) + if !slices.Equal(resolution.suggestions, []string{"alpha", "beta", "gamma"}) { + t.Fatalf("Suggestions = %#v", resolution.suggestions) + } + if got, ok := resolution.Details()["suggestions"].([]string); !ok || !slices.Equal(got, resolution.suggestions) { + t.Fatalf("Details.suggestions = %#v", resolution.Details()["suggestions"]) + } + structured := requireTypedCommandResolution(t, resolution.Err(), ResolutionUnknownSubcommand) + if structured.Message != `unknown subcommand "alphx" for "dws demo"` { + t.Fatalf("Message = %q", structured.Message) + } + if strings.Count(structured.Hint, `"dws demo `) != MaxCommandSuggestions || strings.Contains(structured.Hint, "delta") { + t.Fatalf("Hint = %q", structured.Hint) + } + assertResolutionDetails(t, structured, "alphx", []string{"alpha", "beta", "gamma"}) +} + +func TestCrossPlatformCoverageHintSubCmdUsesSameTypedResolutionFactory(t *testing.T) { + root := &cobra.Command{Use: "dws"} + parent := &cobra.Command{Use: "contact"} + hint := HintSubCmd("department", "use: dws contact dept") + root.AddCommand(parent) + parent.AddCommand(hint) + + structured := requireTypedCommandResolution(t, hint.RunE(hint, nil), ResolutionUnknownSubcommand) + if structured.Hint != "use: dws contact dept (Run 'dws contact --help' for the full list)" { + t.Fatalf("Hint = %q", structured.Hint) + } + if !IsHintOnlyCommand(hint) { + t.Fatal("hint command lost hint-only identity") + } + assertResolutionDetails(t, structured, "department", []string{}) +} + +func TestCrossPlatformCoverageCommandResolutionDefensiveFallbacks(t *testing.T) { + resolution := NewCommandResolution(nil, "missing", ResolutionReason("unsupported"), nil, "") + structured := requireTypedCommandResolution(t, resolution.Err(), ResolutionUnknownSubcommand) + if structured.Message != `unknown subcommand "missing" for "dws"` || structured.Hint != "Run 'dws --help' for the full list" { + t.Fatalf("fallback resolution = %#v", structured) + } + assertResolutionDetails(t, structured, "missing", []string{}) + if suggestions := structured.Details["suggestions"].([]string); suggestions == nil { + t.Fatal("empty suggestions must remain a machine-readable array, not null") + } +} + +func requireTypedCommandResolution(t *testing.T, err error, reason ResolutionReason) *apperrors.Error { + t.Helper() + var structured *apperrors.Error + if !errors.As(err, &structured) { + t.Fatalf("error = %T %v, want *errors.Error", err, err) + } + if structured.Category != apperrors.CategoryValidation || structured.Reason != string(reason) || structured.ExitCode() != 3 { + t.Fatalf("structured error = %#v", structured) + } + return structured +} + +func assertResolutionDetails(t *testing.T, structured *apperrors.Error, input string, suggestions []string) { + t.Helper() + if structured.Details["input"] != input { + t.Fatalf("Details.input = %#v, want %q", structured.Details["input"], input) + } + got, ok := structured.Details["suggestions"].([]string) + if !ok || !slices.Equal(got, suggestions) { + t.Fatalf("Details.suggestions = %#v, want %#v", structured.Details["suggestions"], suggestions) + } +} diff --git a/pkg/cmdutil/command_suggestions.go b/pkg/cmdutil/command_suggestions.go index e2491c5d..17f794f5 100644 --- a/pkg/cmdutil/command_suggestions.go +++ b/pkg/cmdutil/command_suggestions.go @@ -116,6 +116,49 @@ func SuggestSubcommands(parent *cobra.Command, candidate string) []string { return suggestions } +// SuggestDescendantSubcommands returns bounded relative paths for available +// descendants whose canonical name or alias exactly matches candidate. It is +// the reviewed deep-recovery mode used by hierarchical products such as +// Sheet, where a model may omit an intermediate group (`sheet read` instead +// of `sheet range read`). Fuzzy ranking remains a sibling concern so a large +// subtree cannot drown the user in speculative paths. +func SuggestDescendantSubcommands(parent *cobra.Command, candidate string) []string { + if parent == nil { + return nil + } + candidate = strings.ToLower(strings.TrimSpace(candidate)) + if candidate == "" { + return []string{} + } + + paths := make([]string, 0) + var walk func(*cobra.Command, []string) + walk = func(command *cobra.Command, prefix []string) { + for _, child := range command.Commands() { + if !child.IsAvailableCommand() { + continue + } + path := append(append([]string(nil), prefix...), child.Name()) + matched := strings.EqualFold(strings.TrimSpace(child.Name()), candidate) + if !matched { + for _, alias := range child.Aliases { + if strings.EqualFold(strings.TrimSpace(alias), candidate) { + matched = true + break + } + } + } + if matched { + paths = append(paths, strings.Join(path, " ")) + } + walk(child, path) + } + } + walk(parent, nil) + sort.Strings(paths) + return normalizeCommandSuggestions(paths) +} + // FormatSubcommandSuggestionHint renders bounded suggestions and always keeps // the full-list recovery action visible in the same hint. func FormatSubcommandSuggestionHint(parent *cobra.Command, suggestions []string, fallback string) string { @@ -123,8 +166,9 @@ func FormatSubcommandSuggestionHint(parent *cobra.Command, suggestions []string, suggestions = suggestions[:MaxCommandSuggestions] } paths := make([]string, 0, len(suggestions)) + parentPath := commandResolutionParentPath(parent) for _, suggestion := range suggestions { - paths = append(paths, fmt.Sprintf("%q", parent.CommandPath()+" "+suggestion)) + paths = append(paths, fmt.Sprintf("%q", parentPath+" "+suggestion)) } var hint string switch len(paths) { diff --git a/pkg/cmdutil/command_suggestions_test.go b/pkg/cmdutil/command_suggestions_test.go index 9b0d2a1e..69dd2638 100644 --- a/pkg/cmdutil/command_suggestions_test.go +++ b/pkg/cmdutil/command_suggestions_test.go @@ -4,10 +4,12 @@ package cmdutil import ( + "errors" "slices" "strings" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -52,9 +54,12 @@ func TestCrossPlatformCoverageGroupRunERemainsConciseWithoutSuggestions(t *testi } err := GroupRunE(group, []string{"zzzzz"}) - if err == nil || strings.Contains(err.Error(), "available:") || !strings.Contains(err.Error(), "dws demo --help") { + var structured *apperrors.Error + if !errors.As(err, &structured) || structured.Reason != string(ResolutionUnknownSubcommand) || + strings.Contains(structured.Hint, "available:") || structured.Hint != "Run 'dws demo --help' for the full list" { t.Fatalf("GroupRunE() error = %v, want concise parent-help guidance", err) } + assertResolutionDetails(t, structured, "zzzzz", []string{}) } func TestCrossPlatformCoverageFormatSubcommandSuggestionHintDefensivelyBoundsInput(t *testing.T) { @@ -127,3 +132,48 @@ func TestCrossPlatformCoverageSuggestSubcommandsRankingTieBreakers(t *testing.T) t.Fatalf("length-delta ranking = %#v", got) } } + +func TestCrossPlatformCoverageSuggestDescendantSubcommandsExactCanonicalAliasSortedAndBounded(t *testing.T) { + root := &cobra.Command{Use: "sheet"} + addGroup := func(name string, hidden bool) *cobra.Command { + group := &cobra.Command{Use: name, Hidden: hidden, Run: func(*cobra.Command, []string) {}} + root.AddCommand(group) + return group + } + addLeaf := func(group *cobra.Command, use string, aliases []string, hidden, runnable bool) { + leaf := &cobra.Command{Use: use, Aliases: aliases, Hidden: hidden} + if runnable { + leaf.Run = func(*cobra.Command, []string) {} + } + group.AddCommand(leaf) + } + + // Insert matching paths deliberately out of order. The visible alias match + // must teach its canonical path, and the result must be sorted before it is + // capped at the shared three-suggestion limit. + addLeaf(addGroup("zeta", false), "read", nil, false, true) + addLeaf(addGroup("gamma", false), "read", nil, false, true) + addLeaf(addGroup("alpha", false), "fetch", []string{"read"}, false, true) + addLeaf(addGroup("beta", false), "read", nil, false, true) + + // These paths sort before the expected results if they leak through, so the + // assertion also proves hidden and unavailable descendants are excluded + // before bounding rather than merely falling beyond the cap. + addLeaf(addGroup("aaa-hidden-group", true), "read", nil, false, true) + addLeaf(addGroup("aab-unavailable", false), "read", nil, false, false) + addLeaf(addGroup("aac-hidden-leaf", false), "read", nil, true, true) + + want := []string{"alpha fetch", "beta read", "gamma read"} + if got := SuggestDescendantSubcommands(root, " READ "); !slices.Equal(got, want) { + t.Fatalf("SuggestDescendantSubcommands() = %#v, want %#v", got, want) + } + if got := SuggestDescendantSubcommands(root, "rea"); len(got) != 0 { + t.Fatalf("fuzzy descendant suggestions = %#v, want exact matching only", got) + } + if got := SuggestDescendantSubcommands(nil, "read"); got != nil { + t.Fatalf("nil parent suggestions = %#v", got) + } + if got := SuggestDescendantSubcommands(root, " \t "); len(got) != 0 { + t.Fatalf("blank candidate suggestions = %#v", got) + } +} diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go index d35bbc6b..000257d7 100644 --- a/pkg/cmdutil/flags.go +++ b/pkg/cmdutil/flags.go @@ -27,32 +27,6 @@ import ( const hintOnlyCommandAnnotation = "dws.command.hint_only" -// GroupRunE is a reusable RunE for parent (group) commands that have no -// business logic of their own. With args it returns bounded typo guidance; -// without args it shows help. DWS production trees use pipeline.GroupRunE so -// the same diagnostic is also projected as a structured validation error. -func GroupRunE(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - fallback := fmt.Sprintf("Run '%s --help' for the full list", cmd.CommandPath()) - return fmt.Errorf("unknown subcommand %q for %q\n hint: %s", - args[0], cmd.CommandPath(), FormatSubcommandSuggestionHint(cmd, SuggestSubcommands(cmd, args[0]), fallback)) - } - return cmd.Help() -} - -// HintSubCmd creates a hidden subcommand that only prints a disambiguation hint. -func HintSubCmd(use, hint string) *cobra.Command { - return &cobra.Command{ - Use: use, - Hidden: true, - Annotations: map[string]string{hintOnlyCommandAnnotation: "true"}, - RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("ambiguous command %q for %q\n hint: %s", - use, cmd.Parent().CommandPath(), hint) - }, - } -} - // IsHintOnlyCommand reports whether cmd is a hidden compatibility prompt that // has no business execution of its own. Reviewed command-path fallbacks may // supersede these nodes, but must still reject collisions with real commands. diff --git a/pkg/cmdutil/flags_hint_test.go b/pkg/cmdutil/flags_hint_test.go index 50a1b20f..5a96dd2f 100644 --- a/pkg/cmdutil/flags_hint_test.go +++ b/pkg/cmdutil/flags_hint_test.go @@ -4,9 +4,10 @@ package cmdutil import ( - "strings" + "errors" "testing" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/spf13/cobra" ) @@ -21,7 +22,10 @@ func TestCrossPlatformCoverageHintSubCmdCarriesHintOnlyIdentity(t *testing.T) { if IsHintOnlyCommand(nil) || IsHintOnlyCommand(&cobra.Command{Use: "real"}) { t.Fatal("ordinary command was classified as hint-only") } - if err := hint.RunE(hint, nil); err == nil || !strings.Contains(err.Error(), "use parent find") { - t.Fatalf("hint error = %v", err) + var structured *apperrors.Error + if err := hint.RunE(hint, nil); !errors.As(err, &structured) || + structured.Hint != "use parent find (Run 'parent --help' for the full list)" { + t.Fatalf("hint error = %#v", structured) } + assertResolutionDetails(t, structured, "search", []string{}) } diff --git a/pkg/cmdutil/provenance.go b/pkg/cmdutil/provenance.go index b2dba584..9a02f184 100644 --- a/pkg/cmdutil/provenance.go +++ b/pkg/cmdutil/provenance.go @@ -57,25 +57,3 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool { func IsPluginSourced(cmd *cobra.Command) bool { return cmd != nil && cmd.Annotations[SourceAnnotation] == SourcePlugin } - -// KindAnnotation is the annotation key for marking command kinds. -const KindAnnotation = "dws.kind" - -// KindGroup marks a command created as a group container. -const KindGroup = "group" - -// MarkGroup stamps cmd as a group container. -func MarkGroup(cmd *cobra.Command) { - if cmd == nil { - return - } - if cmd.Annotations == nil { - cmd.Annotations = map[string]string{} - } - cmd.Annotations[KindAnnotation] = KindGroup -} - -// IsGroup reports whether cmd was created as a group container. -func IsGroup(cmd *cobra.Command) bool { - return cmd != nil && cmd.Annotations[KindAnnotation] == KindGroup -} diff --git a/test/smoke/cli_smoke_test.go b/test/smoke/cli_smoke_test.go index 77de1123..677ccabf 100644 --- a/test/smoke/cli_smoke_test.go +++ b/test/smoke/cli_smoke_test.go @@ -271,6 +271,27 @@ func TestCrossPlatformCoverageCLISmokeCommandTypoGuidance(t *testing.T) { wantHelp: "dws sheet range --help", wantSuggestion: "read", }, + { + name: "sheet deep path guidance", + args: []string{"sheet", "read", "--format", "json"}, + wantInput: "read", + wantHelp: "dws sheet --help", + wantSuggestion: "range read", + }, + { + name: "hybrid doc import typo", + args: []string{"doc", "import", "gte", "--format", "json"}, + wantInput: "gte", + wantHelp: "dws doc import --help", + wantSuggestion: "get", + }, + { + name: "hybrid sheet import typo", + args: []string{"sheet", "import", "gte", "--format", "json"}, + wantInput: "gte", + wantHelp: "dws sheet import --help", + wantSuggestion: "get", + }, { name: "shared compatibility hint", args: []string{"todo", "create", "--format", "json"}, From 156be4cee738bef522862c3af3539dd4670c271a Mon Sep 17 00:00:00 2001 From: chichuan Date: Tue, 25 Aug 2026 00:49:44 +0800 Subject: [PATCH 27/33] fix: preserve guarded flag values during command resolution --- internal/app/preparse_presentation_test.go | 50 ++++++++ internal/pipeline/cobra.go | 109 +++++++++++++++- internal/pipeline/command_fallback.go | 2 +- internal/pipeline/command_fallback_test.go | 18 +++ internal/pipeline/command_resolution_test.go | 121 ++++++++++++++++++ internal/pipeline/engine.go | 16 +++ internal/pipeline/engine_test.go | 49 +++++++ internal/pipeline/handler.go | 9 ++ internal/pipeline/handlers/semantic_alias.go | 25 ++++ .../pipeline/handlers/semantic_alias_test.go | 28 ++++ 10 files changed, 423 insertions(+), 4 deletions(-) diff --git a/internal/app/preparse_presentation_test.go b/internal/app/preparse_presentation_test.go index 68370d0b..1da970ce 100644 --- a/internal/app/preparse_presentation_test.go +++ b/internal/app/preparse_presentation_test.go @@ -17,6 +17,7 @@ import ( "bytes" stderrors "errors" "io" + "slices" "strings" "testing" @@ -55,6 +56,44 @@ func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t } } +func TestCrossPlatformCoverageFuzzyRootBooleanBetweenGroupAndLeafKeepsLeafPreParse(t *testing.T) { + root := NewSchemaSourceRootCommand() + root.SetOut(io.Discard) + root.SetErr(io.Discard) + args := []string{ + "aisearch", "--query", "Alice", "--yess", "enterprise", + "--queries", "fixture", "--content-types", "document", "--time_range", "本周", "--help", + } + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", args, err) + } + if ctx == nil || ctx.Command != "dws aisearch enterprise" || + !slices.Contains(ctx.Args, "--yes") || !slices.Contains(ctx.Args, "--types") || !slices.Contains(ctx.Args, "--time-range") { + t.Fatalf("group-middle fuzzy flag skipped leaf PreParse: context=%#v", ctx) + } + if err := root.Execute(); err != nil { + t.Fatalf("corrected group-middle persistent flag failed: %v", err) + } +} + +func TestCrossPlatformCoverageProtectedFlagChildNameValueStaysOnOwningCommand(t *testing.T) { + for _, args := range [][]string{ + {"aisearch", "--types", "enterprise"}, + {"aisearch", "--types", "false", "enterprise"}, + {"aisearch", "--types=false", "enterprise"}, + } { + root := NewSchemaSourceRootCommand() + ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args) + if err != nil { + t.Fatalf("RunPreParseArgs(%v) error = %v", args, err) + } + if ctx == nil || ctx.Command != "dws aisearch" || !ctx.IsFlagProtected("types") || !slices.Equal(ctx.Args, args) { + t.Fatalf("protected child-name value selected wrong command: args=%v context=%#v", args, ctx) + } + } +} + func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) { root := NewSchemaSourceRootCommand() args := []string{ @@ -105,6 +144,7 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre args []string wantReason string wantCommand string + wantHint string }{ { name: "unknown shortcut", @@ -118,6 +158,13 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre wantReason: "unknown_subcommand", wantCommand: "dws dev app", }, + { + name: "unknown aisearch subcommand before protected flag", + args: []string{"aisearch", "--query", "Alice", "enterprize", "--types", "enterprise", "--format", "json"}, + wantReason: "unknown_subcommand", + wantCommand: "dws aisearch", + wantHint: "dws aisearch enterprise", + }, } for _, test := range tests { @@ -135,6 +182,9 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre if structured.Reason != test.wantReason || structured.ExitCode() != 3 { t.Fatalf("structured error = %#v", structured) } + if test.wantHint != "" && !strings.Contains(structured.Hint, test.wantHint) { + t.Fatalf("hint = %q, want %q", structured.Hint, test.wantHint) + } if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") { t.Fatalf("command error leaked flag classification: %#v", structured) } diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go index 384aacfc..f20b4817 100644 --- a/internal/pipeline/cobra.go +++ b/internal/pipeline/cobra.go @@ -64,7 +64,7 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co // the unrelated root command `event list`. Remove only known root-persistent // flags from the traversal copy; the original argv remains intact for the // handlers and Cobra's real parse. - target, remaining, err := root.Traverse(argsForCommandTraversal(root, normalizedArgs)) + target, remaining, err := root.Traverse(argsForCommandTraversalForEngine(root, engine, normalizedArgs)) if err != nil { return nil, nil } @@ -129,11 +129,20 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co } func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string { - args, _ := argsForCommandTraversalWithPositions(root, rawArgs) + args, _ := argsForCommandTraversalWithPositionsForEngine(root, nil, rawArgs) + return args +} + +func argsForCommandTraversalForEngine(root *cobra.Command, engine *Engine, rawArgs []string) []string { + args, _ := argsForCommandTraversalWithPositionsForEngine(root, engine, rawArgs) return args } func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) ([]string, []int) { + return argsForCommandTraversalWithPositionsForEngine(root, nil, rawArgs) +} + +func argsForCommandTraversalWithPositionsForEngine(root *cobra.Command, engine *Engine, rawArgs []string) ([]string, []int) { if root == nil || len(rawArgs) == 0 { positions := make([]int, len(rawArgs)) for index := range positions { @@ -153,6 +162,14 @@ func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) matcher := newFlagTokenMatcher(flags) out := make([]string, 0, len(rawArgs)) positions := make([]int, 0, len(rawArgs)) + target := root + commandPathOpen := true + unresolvedCommandToken := "" + unresolvedCommandPosition := -1 + commandTokens := make([]string, 0, len(rawArgs)) + commandPositions := make([]int, 0, len(rawArgs)) + commandFlagMatchers := make(map[*cobra.Command]*flagTokenMatcher) + commandFlagValueIndex := -1 for index := 0; index < len(rawArgs); index++ { argument := rawArgs[index] if argument == "--" { @@ -162,12 +179,86 @@ func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) } break } + if index == commandFlagValueIndex { + out = append(out, argument) + positions = append(positions, index) + commandFlagValueIndex = -1 + continue + } flag, inlineValue, matched := matcher.matchTraversalToken(argument) if !matched { out = append(out, argument) positions = append(positions, index) + if commandPathOpen { + if strings.HasPrefix(argument, "-") { + commandMatcher := commandFlagMatchers[target] + if commandMatcher == nil { + commandMatcher = newFlagTokenMatcher(target.Flags(), target.InheritedFlags()) + commandFlagMatchers[target] = commandMatcher + } + commandFlag, commandInlineValue, commandMatched := commandMatcher.matchTraversalToken(argument) + if commandMatched { + if index+1 < len(rawArgs) { + if _, ok := separatedBoolValue(argument, rawArgs[index+1], commandFlag, commandInlineValue); ok || + (!commandInlineValue && commandFlag.NoOptDefVal == "") { + commandFlagValueIndex = index + 1 + } + } + continue + } + } + child := exactTraversalChild(target, argument) + if child == nil { + commandPathOpen = false + if !strings.HasPrefix(argument, "-") { + unresolvedCommandToken = argument + unresolvedCommandPosition = index + } + } else { + target = child + commandTokens = append(commandTokens, argument) + commandPositions = append(commandPositions, index) + } + } continue } + // Fuzzy matching is useful for a misspelled root boolean before a + // command (`--dry-rnu calendar ...`), but after a product has already + // resolved the same spelling may be an unknown local value flag. For + // example, `aisearch --types value` is deliberately protected from + // alias correction, while root traversal can otherwise fuzz `--types` + // to the global boolean `--yes` and expose `value` as a fake command. + // Preserve the token when the owning command's reviewed semantic table + // protects that spelling. Protection wins even for detached boolean + // literals and inline values: treating `--types false` as fuzzy + // `--yes false` would otherwise bypass the reviewed parent guard. An + // ordinary unprotected fuzzy root boolean such as `group --yess child` + // remains traversable. + if strings.HasPrefix(argument, "--") && flag != nil && flag.NoOptDefVal != "" && + matcher.matchLongToken(argument).fuzzy && target != root { + name := strings.TrimPrefix(argument, "--") + if separator := strings.IndexByte(name, '='); separator >= 0 { + name = name[:separator] + } + _, protected := engine.resolveFlagProtection(target.CommandPath(), cmdutil.Morph(name)) + if protected { + // Freeze traversal at the exact command prefix. Passing the + // protected unknown flag to Cobra can itself fail (notably when + // its value is a boolean literal), while retaining earlier local + // flags can let their values masquerade as children. The synthetic + // terminator affects only this traversal copy; handlers and Cobra's + // real parse still receive the original argv. + out = append(out[:0], commandTokens...) + positions = append(positions[:0], commandPositions...) + if unresolvedCommandToken != "" { + out = append(out, unresolvedCommandToken) + positions = append(positions, unresolvedCommandPosition) + } + out = append(out, "--") + positions = append(positions, index) + break + } + } if index+1 < len(rawArgs) { if _, ok := separatedBoolValue(argument, rawArgs[index+1], flag, inlineValue); ok { index++ @@ -181,6 +272,15 @@ func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) return out, positions } +func exactTraversalChild(parent *cobra.Command, token string) *cobra.Command { + for _, child := range parent.Commands() { + if child.Name() == token || child.HasAlias(token) { + return child + } + } + return nil +} + // separatedBoolValue recognises model-friendly `--flag false` and exact // shorthand `-f false` spellings without mistaking an already attached value // or a shorthand cluster for a detached value. pflag otherwise treats a bare @@ -204,6 +304,7 @@ type longFlagMatch struct { value string hasValue bool recognized bool + fuzzy bool } type flagTokenMatcher struct { @@ -253,6 +354,7 @@ func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch { return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: true} } + fuzzyMatch := false if normalized, ok := NormalizeFlagToken(argument, m.known); ok { canonical = normalized } else if split, ok := SplitStickyFlag(argument, m.specByName); ok { @@ -260,6 +362,7 @@ func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch { return longFlagMatch{flag: m.byName[name], value: split.Value, hasValue: true, recognized: true} } else if fuzzy, ok := FuzzyMatchFlag(argument, m.known, m.candidates); ok { canonical = fuzzy + fuzzyMatch = true } else { return longFlagMatch{} } @@ -267,7 +370,7 @@ func (m *flagTokenMatcher) matchLongToken(argument string) longFlagMatch { body = strings.TrimPrefix(canonical, "--") name, value, hasValue = strings.Cut(body, "=") flag := m.byName[name] - return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil} + return longFlagMatch{flag: flag, value: value, hasValue: hasValue, recognized: flag != nil, fuzzy: fuzzyMatch} } func (m *flagTokenMatcher) matchTraversalToken(argument string) (*pflag.Flag, bool, bool) { diff --git a/internal/pipeline/command_fallback.go b/internal/pipeline/command_fallback.go index 8d47e7e9..3395a007 100644 --- a/internal/pipeline/command_fallback.go +++ b/internal/pipeline/command_fallback.go @@ -24,7 +24,7 @@ func normalizeCommandPathFallback(root *cobra.Command, engine *Engine, rawArgs [ if root == nil || engine == nil || engine.commandPathFallbackLookup == nil || len(rawArgs) == 0 { return rawArgs, nil, nil } - traversalArgs, positions := argsForCommandTraversalWithPositions(root, rawArgs) + traversalArgs, positions := argsForCommandTraversalWithPositionsForEngine(root, engine, rawArgs) entry, tokenCount, ok := longestCommandPathFallback(engine, traversalArgs) if !ok { return rawArgs, nil, nil diff --git a/internal/pipeline/command_fallback_test.go b/internal/pipeline/command_fallback_test.go index 459bccd6..9072421c 100644 --- a/internal/pipeline/command_fallback_test.go +++ b/internal/pipeline/command_fallback_test.go @@ -41,6 +41,24 @@ func TestCrossPlatformCoverageRunPreParseArgsRewritesReviewedCommandBeforeFlags( } } +func TestCrossPlatformCoverageCommandFallbackPrecedesLaterProtectedFlag(t *testing.T) { + root, _ := commandFallbackPipelineRoot() + root.PersistentFlags().Bool("yes", false, "") + engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{ + "chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"}, + }) + engine.Register(reviewedProtectionTestHandler{}) + raw := []string{"chat", "+bad", "--types", "value"} + ctx, err := RunPreParseArgs(root, engine, raw) + if err != nil { + t.Fatal(err) + } + want := []string{"chat", "+good", "--types", "value"} + if ctx == nil || ctx.Command != "dws chat +good" || !reflect.DeepEqual(ctx.Args, want) { + t.Fatalf("fallback before protected flag = %#v, want args=%v", ctx, want) + } +} + func TestCrossPlatformCoverageRunPreParseArgsRewritesMultiTokenPathAroundPersistentFlags(t *testing.T) { root, executed := commandFallbackPipelineRoot() engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{ diff --git a/internal/pipeline/command_resolution_test.go b/internal/pipeline/command_resolution_test.go index 6a2ec5de..1755feb0 100644 --- a/internal/pipeline/command_resolution_test.go +++ b/internal/pipeline/command_resolution_test.go @@ -15,6 +15,35 @@ import ( "github.com/spf13/cobra" ) +type reviewedProtectionTestHandler struct{} + +func (reviewedProtectionTestHandler) Name() string { return "reviewed-protection-test" } + +func (reviewedProtectionTestHandler) Phase() Phase { return PreParse } + +func (reviewedProtectionTestHandler) ResolveFlagProtection(command, flag string) (FlagProtection, bool) { + if (command == "dws aisearch" || command == "dws chat") && flag == "types" { + return FlagProtectionBlocked, true + } + return "", false +} + +func (reviewedProtectionTestHandler) Handle(ctx *Context) error { + if ctx.Command == "dws aisearch" || ctx.Command == "dws chat" { + ctx.ProtectFlag("types", FlagProtectionBlocked) + } + if ctx.Command == "dws aisearch enterprise" { + for index, argument := range ctx.Args { + if argument != "--leaf-typz" { + continue + } + ctx.Args[index] = "--leaf-type" + ctx.AddCorrection("reviewed-protection-test", PreParse, "leaf-type", argument, "--leaf-type", "test") + } + } + return nil +} + func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeFlags(t *testing.T) { root := &cobra.Command{Use: "dws"} chat := &cobra.Command{Use: "chat"} @@ -132,6 +161,98 @@ func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAnd } } +func TestCrossPlatformCoverageRunPreParseArgsKeepsUnknownFlagValuesOutOfCommandResolution(t *testing.T) { + root := &cobra.Command{Use: "dws"} + root.PersistentFlags().Bool("yes", false, "") + aisearch := &cobra.Command{Use: "aisearch", RunE: func(*cobra.Command, []string) error { return nil }} + aisearch.Flags().String("query", "", "") + aisearch.AddCommand(&cobra.Command{Use: "enterprise", Run: func(*cobra.Command, []string) {}}) + corecmd.ApplyGroupPolicy(aisearch, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + root.AddCommand(aisearch) + engine := NewEngine() + engine.Register(reviewedProtectionTestHandler{}) + frozenParentTraversal := []string{"aisearch", "--"} + if got := argsForCommandTraversal(root, []string{"aisearch", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) { + t.Fatalf("default command traversal = %v", got) + } + + guarded := []string{"aisearch", "--types", "FIXTURE_VALUE"} + if got := argsForCommandTraversalForEngine(root, engine, guarded); !slices.Equal(got, frozenParentTraversal) { + t.Fatalf("guarded flag traversal = %v, want %v", got, frozenParentTraversal) + } + if ctx, err := RunPreParseArgs(root, engine, guarded); err != nil || ctx == nil || ctx.Command != "dws aisearch" || !ctx.IsFlagProtected("types") { + t.Fatalf("guarded flag value entered command resolution: context=%#v error=%v", ctx, err) + } + + // A protected flag value can also be spelled exactly like a child. Command + // traversal must not guess which meaning the user intended. + guardedChildValue := []string{"aisearch", "--types", "enterprise"} + if got := argsForCommandTraversalForEngine(root, engine, guardedChildValue); !slices.Equal(got, frozenParentTraversal) { + t.Fatalf("guarded child-name value traversal = %v, want %v", got, frozenParentTraversal) + } + if ctx, err := RunPreParseArgs(root, engine, guardedChildValue); err != nil || ctx == nil || ctx.Command != "dws aisearch" { + t.Fatalf("guarded child-name value entered command resolution: context=%#v error=%v", ctx, err) + } + for _, guardedBooleanValue := range [][]string{ + {"aisearch", "--types", "false", "enterprise"}, + {"aisearch", "--types=false", "enterprise"}, + } { + if got := argsForCommandTraversalForEngine(root, engine, guardedBooleanValue); !slices.Equal(got, frozenParentTraversal) { + t.Fatalf("guarded boolean-like value traversal = %v, want %v", got, frozenParentTraversal) + } + if ctx, err := RunPreParseArgs(root, engine, guardedBooleanValue); err != nil || ctx == nil || ctx.Command != "dws aisearch" { + t.Fatalf("guarded boolean-like value selected wrong command: context=%#v error=%v", ctx, err) + } + } + guardedAfterLocalValue := []string{"aisearch", "--unknown-query", "Alice", "--types", "enterprise"} + if got := argsForCommandTraversalForEngine(root, engine, guardedAfterLocalValue); !slices.Equal(got, frozenParentTraversal) { + t.Fatalf("guarded flag after local value traversal = %v, want %v", got, frozenParentTraversal) + } + if ctx, err := RunPreParseArgs(root, engine, guardedAfterLocalValue); err != nil || ctx == nil || ctx.Command != "dws aisearch" { + t.Fatalf("guarded flag after local value selected wrong command: context=%#v error=%v", ctx, err) + } + legitimateParentFlag := []string{"aisearch", "--query", "Alice", "enterprise", "--types", "document", "--leaf-typz", "value"} + if ctx, err := RunPreParseArgs(root, engine, legitimateParentFlag); err != nil || ctx == nil || ctx.Command != "dws aisearch enterprise" || + !slices.Contains(ctx.Args, "--leaf-type") { + t.Fatalf("parent flag/value prevented leaf resolution: context=%#v error=%v", ctx, err) + } + + // Leading fuzzy root flags remain useful because no command path is + // ambiguous yet. Once a group has resolved, the same fuzzy spelling is + // preserved for the semantic flag pipeline instead of being consumed here. + if got := argsForCommandTraversalForEngine(root, engine, []string{"--yess", "aisearch", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) { + t.Fatalf("leading fuzzy boolean traversal = %v", got) + } + if got := argsForCommandTraversalForEngine(root, engine, []string{"aisearch", "--yess", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) { + t.Fatalf("fuzzy boolean after group traversal = %v", got) + } + if got := argsForCommandTraversalForEngine(root, engine, []string{"aisearch", "--yess", "false", "enterprise"}); !slices.Equal(got, []string{"aisearch", "enterprise"}) { + t.Fatalf("fuzzy boolean value before child traversal = %v", got) + } + leafArgs := []string{"aisearch", "--yess", "enterprise", "--leaf-typz", "value"} + if ctx, err := RunPreParseArgs(root, engine, leafArgs); err != nil || ctx == nil || ctx.Command != "dws aisearch enterprise" || + !slices.Contains(ctx.Args, "--leaf-type") { + t.Fatalf("fuzzy root flag skipped leaf handler: context=%#v error=%v", ctx, err) + } + + _, err := RunPreParseArgs(root, engine, []string{"aisearch", "--yes", "enterprize"}) + requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand)) + _, err = RunPreParseArgs(root, engine, []string{"aisearch", "enterprize", "--types", "enterprise"}) + structured := requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand)) + if suggestions, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(suggestions, []string{"enterprise"}) { + t.Fatalf("typo before protected flag suggestions = %#v", structured.Details["suggestions"]) + } + _, err = RunPreParseArgs(root, engine, []string{"aisearch", "--query", "Alice", "enterprize", "--types", "enterprise"}) + structured = requireCommandResolutionError(t, err, string(cmdutil.ResolutionUnknownSubcommand)) + if suggestions, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(suggestions, []string{"enterprise"}) { + t.Fatalf("typo after parent flag suggestions = %#v", structured.Details["suggestions"]) + } +} + func TestCrossPlatformCoverageRunPreParseArgsUsesTypedDeepRecoveryPolicy(t *testing.T) { root := &cobra.Command{Use: "dws"} sheet := &cobra.Command{Use: "sheet"} diff --git a/internal/pipeline/engine.go b/internal/pipeline/engine.go index fe5d050b..2be3018c 100644 --- a/internal/pipeline/engine.go +++ b/internal/pipeline/engine.go @@ -106,6 +106,22 @@ func (e *Engine) HasHandlers(phase Phase) bool { return len(e.handlers[phase]) > 0 } +func (e *Engine) resolveFlagProtection(rawCommandPath, morphedFlag string) (FlagProtection, bool) { + if e == nil || rawCommandPath == "" || morphedFlag == "" { + return "", false + } + for _, handler := range e.handlers[PreParse] { + resolver, ok := handler.(FlagProtectionResolver) + if !ok { + continue + } + if protection, protected := resolver.ResolveFlagProtection(rawCommandPath, morphedFlag); protected { + return protection, true + } + } + return "", false +} + // RunPhase executes all handlers registered for the given phase in // chain order. If any handler returns an error, execution stops // immediately and the error is returned with the handler name as diff --git a/internal/pipeline/engine_test.go b/internal/pipeline/engine_test.go index 02ed6c25..5bc8a70f 100644 --- a/internal/pipeline/engine_test.go +++ b/internal/pipeline/engine_test.go @@ -30,6 +30,20 @@ type stubHandler struct { callSeq *[]string } +type stubProtectionResolver struct { + *stubHandler + command string + flag string + protection FlagProtection +} + +func (h *stubProtectionResolver) ResolveFlagProtection(command, flag string) (FlagProtection, bool) { + if command == h.command && flag == h.flag { + return h.protection, true + } + return "", false +} + func (h *stubHandler) Name() string { return h.name } func (h *stubHandler) Phase() Phase { return h.phase } func (h *stubHandler) Handle(ctx *Context) error { @@ -79,6 +93,41 @@ func TestCrossPlatformCoverageCommandPathFallbackLookupHandlesNilEngine(t *testi } } +func TestCrossPlatformCoverageEngineResolvesReviewedFlagProtection(t *testing.T) { + var nilEngine *Engine + if protection, ok := nilEngine.resolveFlagProtection("dws demo", "types"); ok || protection != "" { + t.Fatalf("nil engine protection = %q, %t", protection, ok) + } + + engine := NewEngine() + engine.Register(newStub("ordinary", PreParse, nil)) + engine.Register(&stubProtectionResolver{ + stubHandler: newStub("protection", PreParse, nil), + command: "dws demo", + flag: "types", + protection: FlagProtectionBlocked, + }) + for _, test := range []struct { + name string + command string + flag string + want FlagProtection + protected bool + }{ + {name: "empty command", flag: "types"}, + {name: "empty flag", command: "dws demo"}, + {name: "unreviewed", command: "dws demo", flag: "other"}, + {name: "protected", command: "dws demo", flag: "types", want: FlagProtectionBlocked, protected: true}, + } { + t.Run(test.name, func(t *testing.T) { + got, ok := engine.resolveFlagProtection(test.command, test.flag) + if got != test.want || ok != test.protected { + t.Fatalf("resolveFlagProtection(%q, %q) = %q, %t; want %q, %t", test.command, test.flag, got, ok, test.want, test.protected) + } + }) + } +} + func TestRegisterAndHandlerCount(t *testing.T) { e := NewEngine() e.Register(newStub("a", PreParse, nil)) diff --git a/internal/pipeline/handler.go b/internal/pipeline/handler.go index 7bd2a963..0de4edae 100644 --- a/internal/pipeline/handler.go +++ b/internal/pipeline/handler.go @@ -35,3 +35,12 @@ type Handler interface { // the chain, or nil to continue. Handle(ctx *Context) error } + +// FlagProtectionResolver exposes reviewed blocked/ambiguous flag names to +// command traversal. A PreParse handler that owns those decisions implements +// this optional interface so traversal and the handler chain use one semantic +// source instead of independently guessing whether an unknown flag may carry a +// value. +type FlagProtectionResolver interface { + ResolveFlagProtection(rawCommandPath, morphedFlag string) (FlagProtection, bool) +} diff --git a/internal/pipeline/handlers/semantic_alias.go b/internal/pipeline/handlers/semantic_alias.go index 2bd92396..fa3e3226 100644 --- a/internal/pipeline/handlers/semantic_alias.go +++ b/internal/pipeline/handlers/semantic_alias.go @@ -41,6 +41,31 @@ type SemanticAliasHandler struct { func (SemanticAliasHandler) Name() string { return "semantic-alias" } func (SemanticAliasHandler) Phase() pipeline.Phase { return pipeline.PreParse } +// ResolveFlagProtection lets command traversal consult the same reviewed +// blocked/ambiguous table used by Handle. This is intentionally a read-only +// projection: the handler remains responsible for recording the decision on +// the final Context. +func (h SemanticAliasHandler) ResolveFlagProtection(rawCommandPath, morphedFlag string) (pipeline.FlagProtection, bool) { + if h.Lookup == nil || rawCommandPath == "" || morphedFlag == "" { + return "", false + } + _, blocked, ambiguous, ok := h.Lookup(rawCommandPath) + if !ok { + return "", false + } + for _, name := range ambiguous { + if name == morphedFlag { + return pipeline.FlagProtectionAmbiguous, true + } + } + for _, name := range blocked { + if name == morphedFlag { + return pipeline.FlagProtectionBlocked, true + } + } + return "", false +} + func (h SemanticAliasHandler) Handle(ctx *pipeline.Context) error { if h.Lookup == nil || ctx.Command == "" || len(ctx.Args) == 0 { return nil diff --git a/internal/pipeline/handlers/semantic_alias_test.go b/internal/pipeline/handlers/semantic_alias_test.go index f59f4681..346c15b6 100644 --- a/internal/pipeline/handlers/semantic_alias_test.go +++ b/internal/pipeline/handlers/semantic_alias_test.go @@ -62,6 +62,34 @@ func TestSemanticAliasHandlerNameAndPhase(t *testing.T) { } } +func TestCrossPlatformCoverageSemanticAliasHandlerResolvesReviewedProtection(t *testing.T) { + h := newSemanticHandler() + if protection, ok := (SemanticAliasHandler{}).ResolveFlagProtection("dws demo cmd", "count"); ok || protection != "" { + t.Fatalf("nil lookup protection = %q, %t", protection, ok) + } + for _, test := range []struct { + name string + command string + flag string + want pipeline.FlagProtection + protected bool + }{ + {name: "empty command", flag: "count"}, + {name: "empty flag", command: "dws demo cmd"}, + {name: "blocked", command: "dws demo cmd", flag: "count", want: pipeline.FlagProtectionBlocked, protected: true}, + {name: "ambiguous", command: "dws demo cmd", flag: "user-id", want: pipeline.FlagProtectionAmbiguous, protected: true}, + {name: "unreviewed", command: "dws demo cmd", flag: "missing"}, + {name: "other command", command: "dws other", flag: "count"}, + } { + t.Run(test.name, func(t *testing.T) { + got, ok := h.ResolveFlagProtection(test.command, test.flag) + if got != test.want || ok != test.protected { + t.Fatalf("ResolveFlagProtection(%q, %q) = %q, %t; want %q, %t", test.command, test.flag, got, ok, test.want, test.protected) + } + }) + } +} + func TestSemanticAliasHandlerPreservesEqualsValueSyntax(t *testing.T) { ctx := &pipeline.Context{Command: "dws demo cmd", Args: []string{"--pageSize=50"}} if err := newSemanticHandler().Handle(ctx); err != nil { From 61ffc988c576bb66e6c12cd1d390984f3944bd92 Mon Sep 17 00:00:00 2001 From: chichuan Date: Tue, 25 Aug 2026 01:33:52 +0800 Subject: [PATCH 28/33] test: cover command resolution edge cases --- internal/app/plugin_commands_coverage_test.go | 37 ++++++ internal/cobracmd/tree_test.go | 109 ++++++++++++++++++ internal/corecmd/group_policy_test.go | 53 +++++++-- internal/pipeline/command_resolution.go | 6 +- internal/pipeline/command_resolution_test.go | 13 +++ pkg/cmdutil/command_resolution_test.go | 57 +++++++++ 6 files changed, 263 insertions(+), 12 deletions(-) diff --git a/internal/app/plugin_commands_coverage_test.go b/internal/app/plugin_commands_coverage_test.go index 02f5d222..da211e4b 100644 --- a/internal/app/plugin_commands_coverage_test.go +++ b/internal/app/plugin_commands_coverage_test.go @@ -12,6 +12,7 @@ import ( "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes" @@ -30,6 +31,42 @@ func (pluginWrongFlagValue) String() string { return "" } func (pluginWrongFlagValue) Set(string) error { return nil } func (pluginWrongFlagValue) Type() string { return "wrong" } +func TestCrossPlatformCoveragePruneEmptyPluginGroupsRejectsMalformedPolicy(t *testing.T) { + emptyParent := &cobra.Command{Use: "plugin"} + emptyGroup := &cobra.Command{Use: "empty"} + corecmd.ApplyGroupPolicy(emptyGroup, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + emptyParent.AddCommand(emptyGroup) + pruneEmptyPluginGroups(emptyParent) + if len(emptyParent.Commands()) != 0 { + t.Fatalf("empty plugin group was not pruned: %#v", emptyParent.Commands()) + } + + parent := &cobra.Command{Use: "plugin"} + child := &cobra.Command{Use: "group"} + corecmd.ApplyGroupPolicy(child, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, + Positionals: corecmd.PositionalsReject, + Recovery: corecmd.RecoverySibling, + }) + for key := range child.Annotations { + child.Annotations[key] = "malformed" + } + parent.AddCommand(child) + + defer func() { + got := recover() + message, ok := got.(string) + if !ok || !strings.Contains(message, "prune plugin group") { + t.Fatalf("pruneEmptyPluginGroups panic = %v", got) + } + }() + pruneEmptyPluginGroups(parent) +} + func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) { invalidRoot := conferencePluginDescriptor() invalidRoot.CLI.Command = "Invalid Root" diff --git a/internal/cobracmd/tree_test.go b/internal/cobracmd/tree_test.go index 0d3e61c1..07d07147 100644 --- a/internal/cobracmd/tree_test.go +++ b/internal/cobracmd/tree_test.go @@ -278,6 +278,62 @@ func TestCrossPlatformCoverageValidateGroupTree(t *testing.T) { }) } +func TestCrossPlatformCoverageValidateGroupTreeFailsClosedOnCorruption(t *testing.T) { + t.Run("malformed policy metadata", func(t *testing.T) { + root := &cobra.Command{ + Use: "dws", + Annotations: map[string]string{ + "dws.internal.corecmd.group_policy.v1": "malformed", + }, + } + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "invalid GroupPolicy metadata") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("navigation-only execution hook changed", func(t *testing.T) { + root := NewGroupCommand("dws", "root") + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.RunE = nil + root.Run = func(*cobra.Command, []string) {} + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "does not retain framework help execution") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("hybrid business execution hook removed", func(t *testing.T) { + root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }} + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled, + }) + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + root.RunE = nil + root.Run = func(*cobra.Command, []string) {} + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "lost its business RunE") { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("nested validation error is propagated", func(t *testing.T) { + root := NewGroupCommand("dws", "root") + root.AddCommand(NewGroupCommand("stale", "stale")) + if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), `leaf command "dws stale" retains GroupPolicy`) { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) + + t.Run("deep recovery accepts an available descendant", func(t *testing.T) { + root := &cobra.Command{Use: "dws"} + corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{ + Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep, + }) + root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }}) + if err := ValidateGroupTree(root); err != nil { + t.Fatalf("ValidateGroupTree() = %v", err) + } + }) +} + func TestCrossPlatformCoverageMergeCommandTreeGroupPolicy(t *testing.T) { t.Run("copies source declaration", func(t *testing.T) { dst := &cobra.Command{Use: "root"} @@ -431,6 +487,59 @@ func TestCrossPlatformCoverageMergeCommandTreeGroupPolicy(t *testing.T) { }) } +func TestCrossPlatformCoverageMergeCommandTreeFailsClosedOnCorruption(t *testing.T) { + mustPanic := func(t *testing.T, want string, fn func()) { + t.Helper() + defer func() { + got := recover() + message, ok := got.(string) + if !ok || !strings.Contains(message, want) { + t.Fatalf("panic = %v, want substring %q", got, want) + } + }() + fn() + } + + malformed := func() *cobra.Command { + return &cobra.Command{ + Use: "root", + Annotations: map[string]string{ + "dws.internal.corecmd.group_policy.v1": "malformed", + }, + } + } + + t.Run("malformed destination policy", func(t *testing.T) { + mustPanic(t, "destination command", func() { + MergeCommandTree(malformed(), &cobra.Command{Use: "root"}) + }) + }) + + t.Run("malformed source policy", func(t *testing.T) { + mustPanic(t, "source command", func() { + MergeCommandTree(&cobra.Command{Use: "root"}, malformed()) + }) + }) + + t.Run("local flag prevents placeholder merge", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := &cobra.Command{Use: "root"} + src.Flags().String("local", "", "local parse behavior") + mustPanic(t, "behavior-bearing leaf", func() { + MergeCommandTree(dst, src) + }) + }) + + t.Run("persistent flag prevents placeholder merge", func(t *testing.T) { + dst := NewGroupCommand("root", "destination") + src := &cobra.Command{Use: "root"} + src.PersistentFlags().String("persistent", "", "inherited parse behavior") + mustPanic(t, "behavior-bearing leaf", func() { + MergeCommandTree(dst, src) + }) + }) +} + func TestNewHiddenGroupCommand(t *testing.T) { t.Parallel() diff --git a/internal/corecmd/group_policy_test.go b/internal/corecmd/group_policy_test.go index a184f29b..f2a6e31b 100644 --- a/internal/corecmd/group_policy_test.go +++ b/internal/corecmd/group_policy_test.go @@ -81,6 +81,14 @@ func TestCrossPlatformCoverageApplyAndReadGroupPolicy(t *testing.T) { if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") { t.Fatalf("navigation recovery error = %v", err) } + var help strings.Builder + cmd.SetOut(&help) + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("navigation help error = %v", err) + } + if output := help.String(); !strings.Contains(output, "Usage:") { + t.Fatalf("navigation help output = %q", output) + } // Re-applying the same declaration is idempotent. ApplyGroupPolicy(cmd, policy) @@ -94,6 +102,10 @@ func TestCrossPlatformCoverageApplyAndReadGroupPolicy(t *testing.T) { if got, ok, err := GroupPolicyFor(leaf); err != nil || ok || !got.IsZero() { t.Fatalf("leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err) } + annotatedLeaf := &cobra.Command{Use: "annotated-leaf", Annotations: map[string]string{"unrelated": "metadata"}} + if got, ok, err := GroupPolicyFor(annotatedLeaf); err != nil || ok || !got.IsZero() { + t.Fatalf("annotated leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err) + } } func TestCrossPlatformCoverageApplyGroupPolicyDoesNotLeakParentLocalFlags(t *testing.T) { @@ -261,6 +273,11 @@ func TestCrossPlatformCoverageApplyGroupPolicyFailsClosed(t *testing.T) { } mustPanic("nil command", "nil command", func() { ApplyGroupPolicy(nil, GroupPolicy{}) }) mustPanic("zero policy", "zero GroupPolicy", func() { ApplyGroupPolicy(&cobra.Command{Use: "leaf"}, GroupPolicy{}) }) + mustPanic("invalid policy", "invalid GroupPolicy", func() { + ApplyGroupPolicy(&cobra.Command{Use: "broken"}, GroupPolicy{ + Mode: GroupHybrid, Positionals: "unexpected", Recovery: RecoveryDisabled, + }) + }) mustPanic("hybrid must run", "must declare RunE", func() { ApplyGroupPolicy(&cobra.Command{Use: "hybrid"}, GroupPolicy{ Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling, @@ -271,16 +288,36 @@ func TestCrossPlatformCoverageApplyGroupPolicyFailsClosed(t *testing.T) { ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}) ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled}) }) + mustPanic("invalid existing metadata", "invalid GroupPolicy metadata", func() { + cmd := &cobra.Command{ + Use: "broken", + Annotations: map[string]string{ + groupPolicyAnnotation: "hybrid|reject|unexpected", + }, + } + ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}) + }) } func TestCrossPlatformCoverageGroupPolicyForRejectsMalformedPrivateMetadata(t *testing.T) { - cmd := &cobra.Command{ - Use: "broken", - Annotations: map[string]string{ - groupPolicyAnnotation: "hybrid|reject", - }, - } - if _, ok, err := GroupPolicyFor(cmd); err == nil || ok || !strings.Contains(err.Error(), "malformed") { - t.Fatalf("GroupPolicyFor malformed = ok %v, err %v", ok, err) + for name, test := range map[string]struct { + encoded string + needle string + }{ + "malformed": {encoded: "hybrid|reject", needle: "malformed"}, + "invalid policy": {encoded: "hybrid|reject|unexpected", needle: "recovery"}, + "zero policy": {encoded: "||", needle: "must not be zero"}, + } { + t.Run(name, func(t *testing.T) { + cmd := &cobra.Command{ + Use: "broken", + Annotations: map[string]string{ + groupPolicyAnnotation: test.encoded, + }, + } + if _, ok, err := GroupPolicyFor(cmd); err == nil || ok || !strings.Contains(err.Error(), test.needle) { + t.Fatalf("GroupPolicyFor(%q) = ok %v, err %v; want %q", test.encoded, ok, err, test.needle) + } + }) } } diff --git a/internal/pipeline/command_resolution.go b/internal/pipeline/command_resolution.go index 99de102e..5e92d6a1 100644 --- a/internal/pipeline/command_resolution.go +++ b/internal/pipeline/command_resolution.go @@ -48,8 +48,6 @@ func validateUnresolvedCommand(target *cobra.Command, remaining []string) error suggestions = deep } } - if policy.Recovery == corecmd.RecoverySibling || policy.Recovery == corecmd.RecoveryDeep { - return cmdutil.NewCommandResolution(target, candidate, reason, suggestions, "").Err() - } - return nil + // ValidateGroupPolicy and the guard above leave only sibling/deep recovery. + return cmdutil.NewCommandResolution(target, candidate, reason, suggestions, "").Err() } diff --git a/internal/pipeline/command_resolution_test.go b/internal/pipeline/command_resolution_test.go index 1755feb0..09056ba6 100644 --- a/internal/pipeline/command_resolution_test.go +++ b/internal/pipeline/command_resolution_test.go @@ -132,6 +132,19 @@ func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitCon } } +func TestCrossPlatformCoverageValidateUnresolvedCommandRejectsInvalidGroupPolicyMetadata(t *testing.T) { + group := &cobra.Command{Use: "broken"} + applyTestNavigationGroup(group) + for key := range group.Annotations { + group.Annotations[key] = "navigation_only|reject|unexpected" + } + + err := validateUnresolvedCommand(group, []string{"typo"}) + if err == nil || !strings.Contains(err.Error(), "invalid group recovery policy") { + t.Fatalf("validateUnresolvedCommand() error = %v", err) + } +} + func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAndPrimesPresentation(t *testing.T) { root := &cobra.Command{Use: "dws"} root.PersistentFlags().String("format", "table", "") diff --git a/pkg/cmdutil/command_resolution_test.go b/pkg/cmdutil/command_resolution_test.go index 25505eb4..ff942d3e 100644 --- a/pkg/cmdutil/command_resolution_test.go +++ b/pkg/cmdutil/command_resolution_test.go @@ -122,6 +122,63 @@ func TestCrossPlatformCoverageCommandResolutionDefensiveFallbacks(t *testing.T) } } +func TestCrossPlatformCoverageCommandResolutionHelperBranches(t *testing.T) { + group := &cobra.Command{Use: "group"} + group.AddCommand(&cobra.Command{Use: "leaf", Run: func(*cobra.Command, []string) {}}) + var help strings.Builder + group.SetOut(&help) + group.SetErr(&help) + if err := GroupRunE(group, nil); err != nil { + t.Fatalf("GroupRunE help error = %v", err) + } + if output := help.String(); !strings.Contains(output, "Usage:") { + t.Fatalf("GroupRunE help output = %q", output) + } + + hint := HintSubCmd("mistake", "use canonical") + structured := requireTypedCommandResolution(t, hint.RunE(hint, nil), ResolutionUnknownSubcommand) + if structured.Message != `unknown subcommand "mistake" for "mistake"` { + t.Fatalf("detached hint Message = %q", structured.Message) + } + + nameFallback := &cobra.Command{ + Use: "fallback", + Annotations: map[string]string{cobra.CommandDisplayNameAnnotation: " "}, + } + if got := commandResolutionParentPath(nameFallback); got != "fallback" { + t.Fatalf("commandResolutionParentPath name fallback = %q", got) + } + emptyFallback := &cobra.Command{ + Annotations: map[string]string{cobra.CommandDisplayNameAnnotation: ""}, + } + if got := commandResolutionParentPath(emptyFallback); got != "dws" { + t.Fatalf("commandResolutionParentPath empty fallback = %q", got) + } + + parent := &cobra.Command{Use: "parent"} + child := &cobra.Command{Use: "canonical", Aliases: []string{"alias"}} + parent.AddCommand(child) + if hasExactChildCommand(nil, "canonical") { + t.Fatal("nil parent reported an exact child") + } + for _, candidate := range []string{"canonical", "alias"} { + if !hasExactChildCommand(parent, candidate) { + t.Fatalf("hasExactChildCommand(%q) = false", candidate) + } + } + if hasExactChildCommand(parent, "missing") { + t.Fatal("missing child reported as exact") + } + + root := &cobra.Command{Use: "dws"} + service := &cobra.Command{Use: "service"} + service.AddCommand(&cobra.Command{Use: "ordinary"}) + root.AddCommand(service) + if isTopLevelShortcutService(nil) || isTopLevelShortcutService(root) || isTopLevelShortcutService(service) { + t.Fatal("non-shortcut command classified as a top-level shortcut service") + } +} + func requireTypedCommandResolution(t *testing.T, err error, reason ResolutionReason) *apperrors.Error { t.Helper() var structured *apperrors.Error From 01476727e9dfd59afd25fb47f31d41aea3a84b79 Mon Sep 17 00:00:00 2001 From: Dennis Date: Mon, 24 Aug 2026 20:38:47 +0800 Subject: [PATCH 29/33] feat(shortcut): harden DevApp and Agoal task surfaces --- docs/shortcut-public-catalog.json | 251 ++- internal/app/schema_shortcut_contract_test.go | 6 +- internal/shortcut/agoal/agoal.go | 750 +++++++++ ...anged_line_cross_platform_coverage_test.go | 201 +++ .../shortcut/agoal/agoal_hardening_test.go | 434 +++++ internal/shortcut/builtin/builtin.go | 1 + internal/shortcut/devapp/devapp.go | 1440 +++++++++++++---- ...anged_line_cross_platform_coverage_test.go | 742 +++++++++ .../shortcut/devapp/devapp_hardening_test.go | 527 ++++++ .../devapp_pagination_projection_test.go | 26 +- .../shortcut/devapp/devapp_rollout_test.go | 12 +- internal/shortcut/public_catalog_generated.go | 11 + internal/shortcut/runner.go | 17 + internal/shortcut/runner_coverage_test.go | 51 + internal/shortcut/semantic_catalog.go | 8 + internal/shortcut/semantic_catalog_agoal.json | 23 + .../shortcut/semantic_catalog_devapp.json | 37 + ...ev-devapp-agoal-shortcut-dual-live-gate.sh | 798 +++++++++ scripts/gen_shortcut_public_catalog.py | 2 + skills/mono/SKILL.md | 3 +- .../multi/dingtalk-misc/references/devapp.md | 8 +- 21 files changed, 5015 insertions(+), 333 deletions(-) create mode 100644 internal/shortcut/agoal/agoal.go create mode 100644 internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go create mode 100644 internal/shortcut/agoal/agoal_hardening_test.go create mode 100644 internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go create mode 100644 internal/shortcut/devapp/devapp_hardening_test.go create mode 100644 internal/shortcut/semantic_catalog_agoal.json create mode 100644 internal/shortcut/semantic_catalog_devapp.json create mode 100755 scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 36e1beca..3a93cdfb 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,7 +1,57 @@ { - "generated_at": "2026-08-24T12:22:05.108140", - "count": 426, + "generated_at": "2026-08-24T20:14:49.172788", + "count": 437, "results": [ + { + "suite": "semantic", + "service": "agoal", + "command": "+contract-fields", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+obj-template-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+report-statistics-list", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+report-submit-detail", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "agoal", + "command": "+user-rules", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。", + "availability": "available" + }, { "suite": "semantic", "service": "aisearch", @@ -2473,137 +2523,254 @@ "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+create", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", + "service": "devapp", + "command": "+credentials-get", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+delete", "risk": "high-risk-write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+disable", - "risk": "write", - "status": "real-ok" + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+enable", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "启用后按同一 unifiedAppId 读回并要求 appStatus=normal。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+event-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "devapp", + "command": "+event-subscribe", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+member-add", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+member-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+member-remove", - "risk": "write", - "status": "real-ok" + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+permission-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "devapp", + "command": "+robot-config", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "devapp", + "command": "+robot-disable", + "risk": "high-risk-write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "devapp", + "command": "+robot-enable", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+robot-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+update", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+version-check-approval", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "devapp", + "command": "+version-create", + "risk": "write", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。", + "availability": "available" + }, + { + "suite": "semantic", "service": "devapp", "command": "+version-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+version-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+version-status", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。", + "availability": "available" }, { - "suite": "write", + "suite": "semantic", "service": "devapp", "command": "+webapp-config", "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "devapp", "command": "+webapp-get", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。", + "availability": "available" }, { "suite": "semantic", diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index 882a8113..13452541 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 425 + publicShortcutCount = 436 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, // including reviewed hidden compatibility and unavailable contracts. - schemaPublishedShortcutCount = 482 + schemaPublishedShortcutCount = 493 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 425 + publiclyDeliveredShortcutCount = 436 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { diff --git a/internal/shortcut/agoal/agoal.go b/internal/shortcut/agoal/agoal.go new file mode 100644 index 00000000..1d258cbd --- /dev/null +++ b/internal/shortcut/agoal/agoal.go @@ -0,0 +1,750 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package agoal declares reviewed shortcuts for DingTalk Agoal. The public +// surface intentionally stays smaller than the atomic helper surface: only +// reads with strict response contracts and safe live proof are available. +package agoal + +import ( + "encoding/json" + "fmt" + "math" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const productAgoal = "agoal" + +var agoalLoadLocation = time.LoadLocation + +func agoalResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func requireAgoalSuccess(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, agoalResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明查询成功或结果确实为空") + } + rawSuccess, present := data["success"] + if !present { + return nil, agoalResponseError(operation, "missing_success", "响应缺少 success 布尔终态") + } + success, ok := rawSuccess.(bool) + if !ok { + return nil, agoalResponseError(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message, _ := data["message"].(string) + if strings.TrimSpace(message) == "" { + message = "服务明确返回 success=false" + } + return nil, agoalResponseError(operation, "remote_failure", message) + } + for _, key := range []string{"error", "errorCode", "error_code"} { + if value, found := data[key]; found && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" && fmt.Sprint(value) != "0" { + return nil, agoalResponseError(operation, "conflicting_error", fmt.Sprintf("响应 success 与 %s 错误字段冲突", key)) + } + } + return data, nil +} + +func requireAgoalList(data map[string]any, operation string, value any, idKeys ...string) ([]map[string]any, error) { + items, ok := value.([]any) + if !ok { + return nil, agoalResponseError(operation, "malformed_collection", "成功响应的业务集合不是数组") + } + out := make([]map[string]any, 0, len(items)) + for index, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + return nil, agoalResponseError(operation, "malformed_item", fmt.Sprintf("业务集合第 %d 项不是对象", index)) + } + if agoalFirstString(item, idKeys...) == "" { + return nil, agoalResponseError(operation, "missing_item_id", fmt.Sprintf("业务集合第 %d 项缺少稳定身份字段", index)) + } + out = append(out, item) + } + return out, nil +} + +func agoalFirstString(item map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func agoalNonNegativeInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, typed >= 0 + case int64: + if typed < 0 || int64(int(typed)) != typed { + return 0, false + } + return int(typed), true + case float64: + if typed < 0 || math.Trunc(typed) != typed || typed > float64(^uint(0)>>1) { + return 0, false + } + return int(typed), true + case json.Number: + parsed, err := typed.Int64() + if err != nil || parsed < 0 || int64(int(parsed)) != parsed { + return 0, false + } + return int(parsed), true + default: + return 0, false + } +} + +func agoalReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func agoalUnavailableSafety(write bool) contract.SafetySpec { + if write { + return contract.SafetySpec{Effect: "destructive", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} + } + return agoalReadSafety() +} + +func agoalListResult(collection, description string, paged bool, sensitivePaths ...string) *contract.ResultSpec { + properties := map[string]any{ + "count": map[string]any{"type": "integer", "description": "当前结果中的业务记录数量"}, + collection: map[string]any{ + "type": "array", "description": description, + "items": map[string]any{"type": "object", "description": "经稳定身份验证的 Agoal 业务记录", "additionalProperties": true}, + }, + } + required := []string{"count", collection} + if paged { + properties["page"] = map[string]any{"type": "integer", "description": "服务端确认的当前页码"} + properties["pageSize"] = map[string]any{"type": "integer", "description": "服务端确认的当前页容量"} + properties["totalCount"] = map[string]any{"type": "integer", "description": "服务端确认的全部匹配记录数"} + required = append(required, "page", "pageSize", "totalCount") + } + schema, _ := json.Marshal(map[string]any{ + "type": "object", "description": description, "additionalProperties": false, + "properties": properties, "required": required, + }) + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: schema, + SensitivePaths: append([]string(nil), sensitivePaths...), + } +} + +var ReportStatisticsList = shortcut.Shortcut{ + Service: productAgoal, + Command: "+report-statistics-list", + Product: productAgoal, + Description: "查询周月报规则提交统计", + Intent: "当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_report_statistics_list", + CanonicalPath: "agoal.shortcut_report_statistics_list", CLIPath: "agoal +report-statistics-list", PrimaryCLIPath: "agoal +report-statistics-list", + }, + Description: "查询周月报规则提交统计", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed Agoal adapter validates the terminal success marker, collection type, every stable templateId, and the legal empty result before unified output.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询周月报规则提交统计", + UseWhen: []string{"当你要盘点周报或月报规则的按时、迟交、未提交汇总,或按规则关键词定位统计项时使用;返回严格验证的统计列表。"}, + AvoidWhen: []string{"需要查看某一规则下的人员级提交详情时不要使用;该能力尚未完成隐私与分页闭环"}, + Examples: []string{"dws agoal +report-statistics-list --keyword "}, + }, + Result: agoalListResult("statistics", "周月报规则提交统计记录", false, + "statistics.title", "statistics.lastModifier", "statistics.content.name"), + }, + Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "规则名称关键词;传入保证不匹配的关键词可得到合法空集合"}}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("keyword") { + params["keyword"] = rt.Str("keyword") + } + raw, err := rt.CallMCPData(productAgoal, "list_report_statistics", params) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/list_report_statistics"); err != nil { + return err + } + items, err := requireAgoalList(raw, "agoal/list_report_statistics", raw["content"], "templateId") + if err != nil { + return err + } + return rt.Output(map[string]any{"count": len(items), "statistics": items}) + }, +} + +var ObjectTemplateList = shortcut.Shortcut{ + Service: productAgoal, + Command: "+obj-template-list", + Product: productAgoal, + Description: "分页查询 Agoal 目标模板", + Intent: "当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_obj_template_list", + CanonicalPath: "agoal.shortcut_obj_template_list", CLIPath: "agoal +obj-template-list", PrimaryCLIPath: "agoal +obj-template-list", + }, + Description: "分页查询 Agoal 目标模板", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed Agoal adapter validates success, result array, stable template IDs, and explicit page/pageSize/totalCount facts without inventing cursor pagination.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "分页查询 Agoal 目标模板", + UseWhen: []string{"当你要盘点或按关键词查找 Agoal 目标模板,并查看服务端确认的页码、页容量和总数时使用;不会修改模板。"}, + AvoidWhen: []string{"需要新增或覆盖更新模板时不要使用;写能力尚未具备安全回滚合同"}, + Examples: []string{"dws agoal +obj-template-list --keyword --page 1 --page-size 20"}, + }, + Result: agoalListResult("templates", "Agoal 目标模板查询结果", true, + "templates.title", "templates.creator", "templates.dimensions.title"), + }, + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "模板关键词;传入保证不匹配的关键词可得到合法空集合"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("page") < 1 { + return apperrors.NewValidation("--page 必须大于等于 1") + } + if size := rt.Int("page-size"); size < 1 || size > 100 { + return apperrors.NewValidation("--page-size 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + wantPage := rt.Int("page") + wantSize := rt.Int("page-size") + params := map[string]any{"page": wantPage, "pageSize": wantSize} + if rt.Changed("keyword") { + params["keyword"] = rt.Str("keyword") + } + raw, err := rt.CallMCPData(productAgoal, "list_obj_template", params) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/list_obj_template"); err != nil { + return err + } + content, ok := raw["content"].(map[string]any) + if !ok { + return agoalResponseError("agoal/list_obj_template", "malformed_business_result", "成功响应 content 不是分页对象") + } + items, err := requireAgoalList(raw, "agoal/list_obj_template", content["result"], "id", "templateId") + if err != nil { + return err + } + page, pageOK := agoalNonNegativeInt(content["page"]) + pageSize, pageSizeOK := agoalNonNegativeInt(content["pageSize"]) + total, totalOK := agoalNonNegativeInt(content["totalCount"]) + if !pageOK || !pageSizeOK || !totalOK { + return agoalResponseError("agoal/list_obj_template", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount") + } + if page != wantPage || pageSize != wantSize { + return agoalResponseError("agoal/list_obj_template", "pagination_mismatch", "服务端分页回显与请求不一致") + } + if total < len(items) { + return agoalResponseError("agoal/list_obj_template", "impossible_total", "totalCount 小于当前页记录数") + } + return rt.Output(map[string]any{ + "count": len(items), "templates": items, + "page": page, "pageSize": pageSize, "totalCount": total, + }) + }, +} + +var ContractFields = shortcut.Shortcut{ + Service: productAgoal, Command: "+contract-fields", Product: productAgoal, + Description: "查询经营合约字段配置", + Intent: "当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_contract_fields", + CanonicalPath: "agoal.shortcut_contract_fields", CLIPath: "agoal +contract-fields", PrimaryCLIPath: "agoal +contract-fields", + }, + Description: "查询并严格筛选经营合约字段配置", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter requires success, an explicit content array, and a stable id on every field; the optional keyword is applied locally across reviewed scalar field metadata so known and guaranteed-zero cases are provable.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询经营合约字段配置", + UseWhen: []string{"当你要查看当前组织可用的经营合约字段,或按字段 ID、编码、标题、分类、类型精确定位配置时使用;不会修改合约。"}, + AvoidWhen: []string{"查询具体经营合约不要使用;当前合约列表和详情缺少非空安全 fixture"}, + Examples: []string{"dws agoal +contract-fields --keyword "}, + }, + Result: agoalListResult("fields", "经营合约字段配置", false), + }, + Flags: []shortcut.Flag{{Name: "keyword", Type: shortcut.FlagString, Desc: "字段 ID、编码、标题、分类或类型关键词;本地大小写不敏感筛选"}}, + Execute: func(rt *shortcut.RuntimeContext) error { + raw, err := rt.CallMCPData(productAgoal, "list_op_contract_fields", map[string]any{}) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/list_op_contract_fields"); err != nil { + return err + } + fields, err := requireAgoalList(raw, "agoal/list_op_contract_fields", raw["content"], "id") + if err != nil { + return err + } + fields = filterAgoalItems(fields, rt.Str("keyword"), "id", "code", "title", "category", "type") + return rt.Output(map[string]any{"count": len(fields), "fields": fields}) + }, +} + +var UserRules = shortcut.Shortcut{ + Service: productAgoal, Command: "+user-rules", Product: productAgoal, + Description: "查询用户 Agoal 规则周期", + Intent: "当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_user_rules", + CanonicalPath: "agoal.shortcut_user_rules", CLIPath: "agoal +user-rules", PrimaryCLIPath: "agoal +user-rules", + }, + Description: "查询用户规则周期并可按稳定 ruleId 精确筛选", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter requires success, a content object, an explicit rules array, and stable rule IDs; the optional rule-id filter is local exact equality and provides a guaranteed-zero proof path.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "查询用户 Agoal 规则周期", + UseWhen: []string{"当你要查看本人或指定用户可见的 Agoal 规则及周期,或按稳定 ruleId 精确确认某项规则时使用。"}, + AvoidWhen: []string{"需要目标正文时使用 +user-objectives;该目标列表当前缺少非空安全 fixture"}, + Examples: []string{"dws agoal +user-rules --rule-id "}, + }, + Result: agoalListResult("rules", "用户可见的 Agoal 规则与周期", false, + "rules.ruleName", "rules.periodFilter.currentPeriods.nameCn", "rules.periodFilter.historyPeriods.nameCn"), + }, + Flags: []shortcut.Flag{ + {Name: "user-id", Type: shortcut.FlagString, Desc: "可选用户稳定 ID;省略时查询本人"}, + {Name: "rule-id", Type: shortcut.FlagString, Desc: "可选稳定 ruleId;Shortcut 在严格验证完整数组后做精确等值筛选"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{} + if rt.Changed("user-id") { + params["dingUserId"] = rt.Str("user-id") + } + raw, err := rt.CallMCPData(productAgoal, "get_user_rules", params) + if err != nil { + return err + } + if _, err := requireAgoalSuccess(raw, "agoal/get_user_rules"); err != nil { + return err + } + content, ok := raw["content"].(map[string]any) + if !ok { + return agoalResponseError("agoal/get_user_rules", "malformed_business_result", "成功响应 content 不是规则对象") + } + rules, err := requireAgoalList(raw, "agoal/get_user_rules", content["rules"], "id") + if err != nil { + return err + } + wantRule := strings.TrimSpace(rt.Str("rule-id")) + if wantRule != "" { + filtered := make([]map[string]any, 0, 1) + for _, rule := range rules { + if agoalFirstString(rule, "id") == wantRule { + filtered = append(filtered, rule) + } + } + rules = filtered + } + return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + }, +} + +var ReportSubmitDetail = shortcut.Shortcut{ + Service: productAgoal, Command: "+report-submit-detail", Product: productAgoal, + Description: "分页查询周月报人员提交详情", + Intent: "当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。", + Risk: shortcut.RiskRead, + Safety: agoalReadSafety(), + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: productAgoal, Name: "shortcut_report_submit_detail", + CanonicalPath: "agoal.shortcut_report_submit_detail", CLIPath: "agoal +report-submit-detail", PrimaryCLIPath: "agoal +report-submit-detail", + }, + Description: "分页查询周月报人员提交详情", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter requires success, a paged content object, an explicit result array, stable nested user identity on every row, exact page/pageSize echo, and a valid totalCount. Because the lower keyword is ignored, keyword queries use one bounded full traversal with total consistency, duplicate, stall, and page-limit guards before local filtering; only reviewed personnel fields are projected and declared sensitive.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "分页查询周月报人员提交详情", + UseWhen: []string{"当你要按周月报规则、提交状态、日期或人员关键词查看具体人员提交详情时使用;返回严格验证的人员身份和页码事实。"}, + AvoidWhen: []string{"只需规则级汇总时使用 +report-statistics-list;不要把人员级原始结果写入审计文档"}, + Examples: []string{"dws agoal +report-submit-detail --template-id --submit-state NOT_SUBMITTED --page 1 --page-size 20"}, + }, + Result: agoalListResult("submissions", "周月报人员提交详情", true, + "submissions.reportId", "submissions.user.dingUserId", "submissions.user.id", "submissions.user.name", "submissions.user.workNo"), + }, + Flags: []shortcut.Flag{ + {Name: "template-id", Type: shortcut.FlagString, Desc: "周月报规则模板稳定 ID", Required: true}, + {Name: "submit-state", Type: shortcut.FlagString, Desc: "提交状态", Required: true, Enum: []string{"ON_TIME", "LATE", "NOT_SUBMITTED"}}, + {Name: "query-date", Type: shortcut.FlagString, Desc: "可选 ISO-8601 日期或时间"}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "可选人员名称关键词;可用于保证零命中"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,必须大于等于 1"}, + {Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量,必须在 1 到 100 之间"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码,必须大于等于 1"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量,必须在 1 到 100 之间"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("page") < 1 { + return apperrors.NewValidation("--page 必须大于等于 1") + } + if size := rt.Int("page-size"); size < 1 || size > 100 { + return apperrors.NewValidation("--page-size 必须在 1 到 100 之间") + } + if rt.Changed("query-date") { + if _, err := normalizeAgoalQueryDate(rt.Str("query-date")); err != nil { + return err + } + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + wantPage, wantSize := rt.Int("page"), rt.Int("page-size") + baseParams := map[string]any{ + "templateId": rt.Str("template-id"), "submitState": strings.ToUpper(rt.Str("submit-state")), + } + if rt.Changed("query-date") { + // Validate has already accepted this exact immutable Cobra value. + queryDate, _ := normalizeAgoalQueryDate(rt.Str("query-date")) + baseParams["queryDate"] = queryDate + } + keyword := strings.TrimSpace(rt.Str("keyword")) + if keyword == "" { + items, total, err := readAgoalSubmissionPage(rt, baseParams, wantPage, wantSize) + if err != nil { + return err + } + return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": total}) + } + all, err := readAllAgoalSubmissions(rt, baseParams) + if err != nil { + return err + } + filtered := filterAgoalSubmissions(all, keyword) + start := len(filtered) + pageOffset := wantPage - 1 + if pageOffset <= len(filtered)/wantSize { + start = pageOffset * wantSize + } + end := start + wantSize + if end > len(filtered) { + end = len(filtered) + } + items := filtered[start:end] + return rt.Output(map[string]any{"count": len(items), "submissions": items, "page": wantPage, "pageSize": wantSize, "totalCount": len(filtered)}) + }, +} + +const ( + agoalSubmissionReadPageSize = 100 + agoalSubmissionMaxPages = 20 +) + +func readAgoalSubmissionPage(rt *shortcut.RuntimeContext, baseParams map[string]any, page, pageSize int) ([]map[string]any, int, error) { + params := make(map[string]any, len(baseParams)+2) + for key, value := range baseParams { + params[key] = value + } + params["page"] = page + params["pageSize"] = pageSize + raw, err := rt.CallMCPData(productAgoal, "get_submit_detail", params) + if err != nil { + return nil, 0, err + } + if _, err := requireAgoalSuccess(raw, "agoal/get_submit_detail"); err != nil { + return nil, 0, err + } + content, ok := raw["content"].(map[string]any) + if !ok { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_business_result", "成功响应 content 不是分页对象") + } + items, err := requireAgoalSubmissionList(content["result"]) + if err != nil { + return nil, 0, err + } + gotPage, pageOK := agoalNonNegativeInt(content["page"]) + gotSize, sizeOK := agoalNonNegativeInt(content["pageSize"]) + total, totalOK := agoalNonNegativeInt(content["totalCount"]) + if !pageOK || !sizeOK || !totalOK { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "malformed_pagination", "分页响应缺少非负整数 page/pageSize/totalCount") + } + if gotPage != page || gotSize != pageSize { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "pagination_mismatch", "服务端分页回显与请求不一致") + } + if total < len(items) { + return nil, 0, agoalResponseError("agoal/get_submit_detail", "impossible_total", "totalCount 小于当前页记录数") + } + return items, total, nil +} + +func readAllAgoalSubmissions(rt *shortcut.RuntimeContext, baseParams map[string]any) ([]map[string]any, error) { + all := make([]map[string]any, 0) + seen := map[string]bool{} + wantTotal := -1 + for page := 1; page <= agoalSubmissionMaxPages; page++ { + items, total, err := readAgoalSubmissionPage(rt, baseParams, page, agoalSubmissionReadPageSize) + if err != nil { + return nil, err + } + if wantTotal < 0 { + wantTotal = total + } else if total != wantTotal { + return nil, agoalResponseError("agoal/get_submit_detail", "pagination_total_changed", "分页遍历期间 totalCount 发生变化") + } + for _, item := range items { + identity := agoalSubmissionIdentity(item) + if seen[identity] { + return nil, agoalResponseError("agoal/get_submit_detail", "pagination_duplicate", "分页遍历出现重复人员提交身份") + } + seen[identity] = true + all = append(all, item) + } + if len(all) == wantTotal { + return all, nil + } + if len(items) == 0 { + return nil, agoalResponseError("agoal/get_submit_detail", "pagination_stall", "尚有记录时服务返回空页,分页无法推进") + } + } + return nil, agoalResponseError("agoal/get_submit_detail", "readback_page_limit", "人员提交详情超过有界分页上限") +} + +func agoalSubmissionIdentity(item map[string]any) string { + if reportID := agoalFirstString(item, "reportId"); reportID != "" { + return "report:" + reportID + } + user, _ := item["user"].(map[string]any) + return "user:" + agoalFirstString(user, "dingUserId", "id") +} + +func filterAgoalSubmissions(items []map[string]any, keyword string) []map[string]any { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if keyword == "" { + return items + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + user, _ := item["user"].(map[string]any) + if strings.Contains(strings.ToLower(agoalFirstString(user, "name")), keyword) { + out = append(out, item) + } + } + return out +} + +func filterAgoalItems(items []map[string]any, keyword string, keys ...string) []map[string]any { + keyword = strings.ToLower(strings.TrimSpace(keyword)) + if keyword == "" { + return items + } + out := make([]map[string]any, 0, len(items)) + for _, item := range items { + for _, key := range keys { + value, ok := item[key].(string) + if ok && strings.Contains(strings.ToLower(value), keyword) { + out = append(out, item) + break + } + } + } + return out +} + +func requireAgoalSubmissionList(value any) ([]map[string]any, error) { + rawItems, ok := value.([]any) + if !ok { + return nil, agoalResponseError("agoal/get_submit_detail", "malformed_collection", "成功响应 result 不是数组") + } + out := make([]map[string]any, 0, len(rawItems)) + for index, raw := range rawItems { + item, ok := raw.(map[string]any) + if !ok { + return nil, agoalResponseError("agoal/get_submit_detail", "malformed_item", fmt.Sprintf("人员提交详情第 %d 项不是对象", index)) + } + user, ok := item["user"].(map[string]any) + if !ok || agoalFirstString(user, "dingUserId", "id") == "" { + return nil, agoalResponseError("agoal/get_submit_detail", "missing_item_id", fmt.Sprintf("人员提交详情第 %d 项缺少稳定用户身份", index)) + } + projectedUser := map[string]any{} + for _, key := range []string{"dingUserId", "id", "name", "workNo"} { + if value, found := user[key]; found { + projectedUser[key] = value + } + } + projected := map[string]any{"user": projectedUser} + for _, key := range []string{"reportId", "publishTime"} { + if value, found := item[key]; found { + projected[key] = value + } + } + out = append(out, projected) + } + return out, nil +} + +func normalizeAgoalQueryDate(value string) (string, error) { + value = strings.TrimSpace(value) + if parsed, err := time.Parse("2006-01-02", value); err == nil { + return parsed.Format("2006-01-02"), nil + } + if parsed, err := time.Parse(time.RFC3339, value); err == nil { + location, loadErr := agoalLoadLocation("Asia/Shanghai") + if loadErr != nil { + location = time.FixedZone("CST", 8*60*60) + } + return parsed.In(location).Format("2006-01-02"), nil + } + return "", apperrors.NewValidation("--query-date 必须是 YYYY-MM-DD 或 RFC3339 时间") +} + +type unavailableAgoalSpec struct { + command string + description string + intent string + tool string + flags []shortcut.Flag + write bool + reason string +} + +func agoalStringFlag(name, description string, required bool) shortcut.Flag { + return shortcut.Flag{Name: name, Type: shortcut.FlagString, Desc: description, Required: required} +} + +func unavailableAgoalShortcut(spec unavailableAgoalSpec) shortcut.Shortcut { + flagNames := make([]string, 0, len(spec.flags)) + for _, flag := range spec.flags { + flagNames = append(flagNames, flag.Name) + } + unavailable := func(*shortcut.RuntimeContext) error { + return apperrors.NewValidation("该 Agoal Shortcut 当前 unavailable:" + spec.reason) + } + return shortcut.Shortcut{ + Service: productAgoal, Command: spec.command, Product: productAgoal, + Description: spec.description, Intent: spec.intent, + Risk: func() shortcut.Risk { + if spec.write { + return shortcut.RiskHighWrite + } + return shortcut.RiskRead + }(), + Safety: agoalUnavailableSafety(spec.write), + Flags: spec.flags, + Hidden: true, + Availability: shortcut.AvailabilityUnavailable, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, Flags: flagNames, + Description: "该命令保持 unavailable,直到文档化的安全验证前置条件满足", + }}, + SemanticDelta: "Atomic route " + spec.tool + " remains unavailable: " + spec.reason, + Validate: unavailable, + Execute: unavailable, + } +} + +var unavailableAgoalSpecs = []unavailableAgoalSpec{ + {command: "+strategy-list", description: "按范围查询战略解码", intent: "按部门或个人范围查询战略解码列表。", tool: "list_strategy_decodings", flags: []shortcut.Flag{ + {Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}}, + agoalStringFlag("scope-id", "部门或人员稳定 ID", true), + }, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"}, + {command: "+strategy-get", description: "查询战略解码详情", intent: "按 profileId 查询单个战略解码。", tool: "get_strategy_decoding_detail", flags: []shortcut.Flag{ + agoalStringFlag("profile-id", "战略解码稳定 ID", true), + }, reason: "raw 对不存在 profileId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"}, + {command: "+strategy-update", description: "覆盖更新战略解码", intent: "基于旧数据覆盖更新战略解码。", tool: "update_strategy_decoding", write: true, flags: []shortcut.Flag{ + agoalStringFlag("profile-id", "战略解码稳定 ID", true), agoalStringFlag("content", "完整实体 JSON 数组", true), + }, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"}, + {command: "+contract-list", description: "按范围查询经营合约", intent: "按部门或个人范围查询经营合约列表。", tool: "list_op_contracts", flags: []shortcut.Flag{ + {Name: "scope-type", Type: shortcut.FlagString, Desc: "范围类型", Required: true, Enum: []string{"DEPT", "PERSONAL"}}, + agoalStringFlag("scope-id", "部门或人员稳定 ID", true), + }, reason: "exact+raw 在当前可访问 PERSONAL 与 DEPT 范围均仅返回显式空数组,缺少已知非空 fixture"}, + {command: "+contract-get", description: "查询经营合约详情", intent: "按 contractId 查询单个经营合约。", tool: "get_op_contract_detail", flags: []shortcut.Flag{ + agoalStringFlag("contract-id", "经营合约稳定 ID", true), + }, reason: "raw 对不存在 contractId 返回 null 且无 success 终态,列表又无非空 fixture,无法绑定稳定身份"}, + {command: "+contract-update", description: "覆盖更新经营合约", intent: "基于旧数据覆盖经营合约全部维度。", tool: "update_op_contract", write: true, flags: []shortcut.Flag{ + agoalStringFlag("contract-id", "经营合约稳定 ID", true), agoalStringFlag("dimensions", "完整维度 JSON 数组", true), + }, reason: "raw 仅完成 dry-run payload;没有可读旧值 fixture、全字段精确读回与恢复闭环"}, + {command: "+scorecard-get", description: "查询计分卡详情", intent: "按部门与周期查询计分卡。", tool: "get_score_card_detail", flags: []shortcut.Flag{ + agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("dept-id", "部门稳定 ID", true), + }, reason: "exact+raw 对可访问部门和多个受控周期均返回 null 且无 success 终态,缺少非空计分卡 fixture"}, + {command: "+scorecard-entity-get", description: "查询计分卡实体详情", intent: "按计分卡与实体 ID 查询详情。", tool: "get_score_card_entity_detail", flags: []shortcut.Flag{ + agoalStringFlag("sc-id", "计分卡稳定 ID", true), agoalStringFlag("entity-id", "实体稳定 ID", true), + }, reason: "raw 对不存在计分卡/实体双 ID 返回 null 且无 success 终态,无法取得可安全读取的双 ID fixture"}, + {command: "+scorecard-update", description: "覆盖更新计分卡", intent: "基于旧数据覆盖更新计分卡内容。", tool: "update_score_card", write: true, flags: []shortcut.Flag{ + agoalStringFlag("dept-id", "部门稳定 ID", true), agoalStringFlag("selected-time", "ISO-8601 周期起始时间", true), agoalStringFlag("id", "计分卡稳定 ID", true), + {Name: "tracking-period-type", Type: shortcut.FlagString, Desc: "跟踪周期", Required: true, Enum: []string{"MONTHLY", "QUARTERLY"}}, + agoalStringFlag("content", "完整计分卡 JSON 数组", true), + }, reason: "raw 仅完成 dry-run payload;没有可读计分卡 fixture、全字段精确读回与恢复闭环"}, + {command: "+user-objectives", description: "查询用户目标", intent: "按用户、规则与周期查询目标。", tool: "list_user_objectives", flags: []shortcut.Flag{ + agoalStringFlag("user-id", "人员稳定 ID", true), agoalStringFlag("rule-id", "规则稳定 ID", true), agoalStringFlag("period-ids", "周期 ID CSV", true), + }, reason: "exact+raw 使用当前用户及全部可见规则/周期均只返回显式空数组,缺少已知非空目标 fixture"}, + {command: "+obj-template-upsert", description: "新增或覆盖更新目标模板", intent: "新增目标模板或基于旧数据覆盖更新模板。", tool: "create_or_update_obj_template", write: true, flags: []shortcut.Flag{ + agoalStringFlag("template-id", "更新时的模板稳定 ID", false), agoalStringFlag("title", "新增时的模板标题", false), agoalStringFlag("dimensions", "完整维度 JSON", true), + {Name: "objective-weight", Type: shortcut.FlagBool, Desc: "是否启用目标权重"}, + {Name: "dimension-weight", Type: shortcut.FlagBool, Desc: "是否启用维度权重"}, + {Name: "compute-by-weight", Type: shortcut.FlagBool, Desc: "维度是否参与计算"}, + }, reason: "raw 仅完成 dry-run payload;原子面无模板删除或版本恢复,无法验证 create/update 终态与零残留"}, +} + +func init() { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: productAgoal, + Selection: contract.ProductSelectionDecl{ + AgentSummary: "查询钉钉 Agoal 的周月报统计、人员提交详情、合约字段、用户规则与目标模板;其余能力按安全证据逐步开放", + UseWhen: []string{"用户明确处理钉钉 Agoal、战略解码、经营合约、计分卡、周月报统计或目标模板时使用"}, + AvoidWhen: []string{"用户处理 Lark OKR 的 Objective、Key Result、Progress 或 Alignment 时不要使用;两者数据模型和平台不同"}, + }, + }) + items := []shortcut.Shortcut{ReportStatisticsList, ObjectTemplateList, ContractFields, UserRules, ReportSubmitDetail} + for _, spec := range unavailableAgoalSpecs { + items = append(items, unavailableAgoalShortcut(spec)) + } + for index := range items { + if items[index].Availability != shortcut.AvailabilityUnavailable { + items[index].OutputRollout = output.RolloutUnifiedActive + } + } + shortcut.Register(items...) +} diff --git a/internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go b/internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..cc831ecc --- /dev/null +++ b/internal/shortcut/agoal/agoal_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,201 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package agoal + +import ( + "encoding/json" + "errors" + "math" + "strconv" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageAgoalScalarAndProjectionEdges(t *testing.T) { + for _, tc := range []struct { + name string + value any + want int + ok bool + }{ + {name: "int", value: 3, want: 3, ok: true}, + {name: "negative int", value: -1, want: -1}, + {name: "int64", value: int64(4), want: 4, ok: true}, + {name: "negative int64", value: int64(-1)}, + {name: "float", value: float64(5), want: 5, ok: true}, + {name: "negative float", value: float64(-1)}, + {name: "fraction", value: 1.5}, + {name: "huge float", value: math.MaxFloat64}, + {name: "json number", value: json.Number("6"), want: 6, ok: true}, + {name: "bad json number", value: json.Number("1.5")}, + {name: "negative json number", value: json.Number("-1")}, + {name: "wrong type", value: "7"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, ok := agoalNonNegativeInt(tc.value) + if got != tc.want || ok != tc.ok { + t.Fatalf("agoalNonNegativeInt(%#v) = (%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok) + } + }) + } + + items := []map[string]any{{"id": "one", "name": "Known"}} + if got := filterAgoalItems(items, "", "name"); len(got) != 1 { + t.Fatalf("empty item filter = %#v", got) + } + if got := filterAgoalSubmissions(items, ""); len(got) != 1 { + t.Fatalf("empty submission filter = %#v", got) + } + if got := agoalSubmissionIdentity(map[string]any{"user": map[string]any{"id": "user"}}); got != "user:user" { + t.Fatalf("fallback identity = %q", got) + } + + for _, bad := range []any{ + nil, + []any{"bad"}, + []any{map[string]any{"user": "bad"}}, + []any{map[string]any{"user": map[string]any{"name": "missing identity"}}}, + } { + if _, err := requireAgoalSubmissionList(bad); err == nil { + t.Fatalf("malformed submission list accepted: %#v", bad) + } + } +} + +func TestCrossPlatformCoverageAgoalDateNormalizationEdges(t *testing.T) { + if got, err := normalizeAgoalQueryDate(" 2026-08-24 "); err != nil || got != "2026-08-24" { + t.Fatalf("date normalization = %q, %v", got, err) + } + if got, err := normalizeAgoalQueryDate("2026-08-24T01:00:00Z"); err != nil || got != "2026-08-24" { + t.Fatalf("RFC3339 normalization = %q, %v", got, err) + } + testseam.Swap(t, &agoalLoadLocation, func(string) (*time.Location, error) { + return nil, errors.New("fixture location failure") + }) + if got, err := normalizeAgoalQueryDate("2026-08-24T18:00:00Z"); err != nil || got != "2026-08-25" { + t.Fatalf("fallback timezone normalization = %q, %v", got, err) + } +} + +func TestCrossPlatformCoverageAgoalLeafFailureWiring(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response map[string]any + }{ + {name: "report transport", declaration: ReportStatisticsList, tool: "list_report_statistics"}, + {name: "report failure", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": false}}, + {name: "report conflicting error", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}}, + {name: "report missing success", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"content": []any{}}}, + {name: "report malformed items", declaration: ReportStatisticsList, tool: "list_report_statistics", response: map[string]any{"success": true, "content": []any{"bad"}}}, + {name: "template transport", declaration: ObjectTemplateList, tool: "list_obj_template"}, + {name: "template failure", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": false}}, + {name: "template conflicting error", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}}, + {name: "template missing success", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"content": map[string]any{}}}, + {name: "template content", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": []any{}}}, + {name: "template items", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}}, + {name: "template pagination", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}}, + {name: "template page mismatch", declaration: ObjectTemplateList, tool: "list_obj_template", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 2, "pageSize": 20, "totalCount": 0}}}, + {name: "fields transport", declaration: ContractFields, tool: "list_op_contract_fields"}, + {name: "fields failure", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": false}}, + {name: "fields conflicting error", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}}, + {name: "fields missing success", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"content": []any{}}}, + {name: "fields items", declaration: ContractFields, tool: "list_op_contract_fields", response: map[string]any{"success": true, "content": []any{"bad"}}}, + {name: "rules transport", declaration: UserRules, tool: "get_user_rules"}, + {name: "rules failure", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": false}}, + {name: "rules conflicting error", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}}, + {name: "rules missing success", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"content": map[string]any{}}}, + {name: "rules content", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": []any{}}}, + {name: "rules items", declaration: UserRules, tool: "get_user_rules", response: map[string]any{"success": true, "content": map[string]any{"rules": []any{"bad"}}}}, + {name: "submission transport", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail"}, + {name: "submission failure", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": false}}, + {name: "submission conflicting error", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "errorCode": "DENIED", "content": map[string]any{}}}, + {name: "submission missing success", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"content": map[string]any{}}}, + {name: "submission content", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": []any{}}}, + {name: "submission items", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": "bad", "page": 1, "pageSize": 20, "totalCount": 0}}}, + {name: "submission pagination", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": "1", "pageSize": 20, "totalCount": 0}}}, + {name: "submission size mismatch", declaration: ReportSubmitDetail, args: []string{"--template-id", "template", "--submit-state", "ON_TIME"}, tool: "get_submit_detail", response: map[string]any{"success": true, "content": map[string]any{"result": []any{}, "page": 1, "pageSize": 21, "totalCount": 0}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &agoalCoverageCaller{} + if tc.response != nil { + caller.responses = map[string][]map[string]any{tc.tool: {tc.response}} + } + if err := runAgoalCoverage(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("failure fixture was accepted") + } + }) + } + + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_user_rules": {{"success": true, "content": map[string]any{"rules": []any{map[string]any{"id": "rule"}}}}}, + }} + if err := runAgoalCoverage(t, UserRules, caller); err != nil { + t.Fatal(err) + } +} + +func TestCrossPlatformCoverageAgoalBoundedPaginationAndDateExecution(t *testing.T) { + responses := make([]map[string]any, 0, agoalSubmissionMaxPages) + for page := 1; page <= agoalSubmissionMaxPages; page++ { + responses = append(responses, map[string]any{"success": true, "content": map[string]any{ + "page": page, "pageSize": agoalSubmissionReadPageSize, "totalCount": agoalSubmissionMaxPages + 1, + "result": []any{map[string]any{"reportId": "report-" + json.Number(string(rune('A'+page-1))).String(), "user": map[string]any{"id": "user"}}}, + }}) + } + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{"get_submit_detail": responses}} + if err := runAgoalCoverage(t, ReportSubmitDetail, caller, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil { + t.Fatal("bounded pagination limit was accepted as complete") + } + + scanFailure := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"content": map[string]any{}}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, scanFailure, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "known"); err == nil { + t.Fatal("scan page validation failure was accepted") + } + + dateCaller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": 20, "totalCount": 0, "result": []any{}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, dateCaller, + "--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "2026-08-24T01:00:00Z"); err != nil { + t.Fatal(err) + } + if got := dateCaller.calls[0].params["queryDate"]; got != "2026-08-24" { + t.Fatalf("queryDate = %#v", got) + } + + laterPage := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1, + "result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, laterPage, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing", "--page", "2"); err != nil { + t.Fatal(err) + } + maximumPage := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 1, + "result": []any{map[string]any{"reportId": "report", "user": map[string]any{"id": "user", "name": "Different"}}}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, maximumPage, + "--template-id", "template", "--submit-state", "ON_TIME", "--keyword", "missing", + "--page", strconv.Itoa(int(^uint(0)>>1))); err != nil { + t.Fatalf("maximum page must project an empty local page without overflowing: %v", err) + } +} diff --git a/internal/shortcut/agoal/agoal_hardening_test.go b/internal/shortcut/agoal/agoal_hardening_test.go new file mode 100644 index 00000000..9c3aba7c --- /dev/null +++ b/internal/shortcut/agoal/agoal_hardening_test.go @@ -0,0 +1,434 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package agoal + +import ( + "context" + "encoding/json" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type agoalCoverageCall struct { + tool string + params map[string]any +} + +type agoalCoverageCaller struct { + responses map[string][]map[string]any + calls []agoalCoverageCall +} + +func (c *agoalCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, agoalCoverageCall{tool: tool, params: params}) + queue := c.responses[tool] + if len(queue) == 0 { + return nil, errors.New("unexpected agoal coverage call: " + tool) + } + payload := queue[0] + c.responses[tool] = queue[1:] + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil +} + +func (*agoalCoverageCaller) Format() string { return "json" } +func (*agoalCoverageCaller) DryRun() bool { return false } +func (*agoalCoverageCaller) Fields() string { return "" } +func (*agoalCoverageCaller) JQ() string { return "" } + +func runAgoalCoverage(t *testing.T, declaration shortcut.Shortcut, caller *agoalCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: productAgoal} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{productAgoal, declaration.Command}, args...)) + return root.Execute() +} + +func agoalRegistered(command string) (shortcut.Shortcut, bool) { + for _, item := range shortcut.All() { + if item.Service == productAgoal && item.Command == command { + return item, true + } + } + return shortcut.Shortcut{}, false +} + +func TestCrossPlatformCoverageAgoalStrictResponseValidation(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "empty", data: nil}, + {name: "missing success", data: map[string]any{"content": []any{}}}, + {name: "wrong success", data: map[string]any{"success": "true", "content": []any{}}}, + {name: "explicit failure", data: map[string]any{"success": false, "content": []any{}}}, + {name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "content": []any{}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := requireAgoalSuccess(tc.data, "agoal/test"); err == nil { + t.Fatal("malformed response was accepted") + } + }) + } + + for _, tc := range []struct { + name string + value any + }{ + {name: "missing collection", value: nil}, + {name: "wrong collection", value: map[string]any{}}, + {name: "bad element", value: []any{"bad"}}, + {name: "missing id", value: []any{map[string]any{"title": "template"}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := requireAgoalList(map[string]any{"success": true}, "agoal/test", tc.value, "id"); err == nil { + t.Fatal("malformed collection was accepted") + } + }) + } +} + +func TestCrossPlatformCoverageAgoalPublicReadsUseExactToolsAndShapes(t *testing.T) { + t.Run("report nonempty", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_report_statistics": {{"success": true, "content": []any{map[string]any{"templateId": "template"}}}}, + }} + if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "weekly"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "list_report_statistics", params: map[string]any{"keyword": "weekly"}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("report legal zero", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_report_statistics": {{"success": true, "content": []any{}}}, + }} + if err := runAgoalCoverage(t, ReportStatisticsList, caller, "--keyword", "guaranteed-zero"); err != nil { + t.Fatal(err) + } + }) + + t.Run("template nonempty page", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_obj_template": {{"success": true, "content": map[string]any{ + "page": 2, "pageSize": 5, "totalCount": 6, + "result": []any{map[string]any{"id": "template"}}, + }}}, + }} + if err := runAgoalCoverage(t, ObjectTemplateList, caller, "--keyword", "target", "--page", "2", "--page-size", "5"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "list_obj_template", params: map[string]any{"keyword": "target", "page": 2, "pageSize": 5}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("template rejects false pagination", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_obj_template": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": 20, "totalCount": 0, + "result": []any{map[string]any{"id": "impossible"}}, + }}}, + }} + if err := runAgoalCoverage(t, ObjectTemplateList, caller); err == nil { + t.Fatal("impossible pagination total was accepted") + } + }) +} + +func TestCrossPlatformCoverageAgoalResidualReads(t *testing.T) { + t.Run("contract fields known and guaranteed zero", func(t *testing.T) { + items := []map[string]any{{"id": "field-1", "code": "revenue", "title": "Revenue"}} + if got := filterAgoalItems(items, "revenue", "id", "code", "title"); len(got) != 1 { + t.Fatalf("known field projection = %#v", got) + } + if got := filterAgoalItems(items, "guaranteed-zero", "id", "code", "title"); len(got) != 0 { + t.Fatalf("zero field projection = %#v", got) + } + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "list_op_contract_fields": {{"success": true, "content": []any{items[0]}}}, + }} + if err := runAgoalCoverage(t, ContractFields, caller, "--keyword", "revenue"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "list_op_contract_fields", params: map[string]any{}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("user rules exact filter", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_user_rules": {{"success": true, "content": map[string]any{ + "rules": []any{map[string]any{"id": "rule-1", "ruleName": "Rule"}}, + }}}, + }} + if err := runAgoalCoverage(t, UserRules, caller, "--user-id", "user", "--rule-id", "rule-1"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "get_user_rules", params: map[string]any{"dingUserId": "user"}}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + zero := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_user_rules": {{"success": true, "content": map[string]any{ + "rules": []any{map[string]any{"id": "rule-1"}}, + }}}, + }} + if err := runAgoalCoverage(t, UserRules, zero, "--rule-id", "guaranteed-zero"); err != nil { + t.Fatal(err) + } + }) + + t.Run("report submission page and privacy identity", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": 20, "totalCount": 1, + "result": []any{map[string]any{"reportId": nil, "user": map[string]any{"dingUserId": "user"}}}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--page", "1", "--page-size", "20"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{ + "templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": 20, + }}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("report keyword scans then filters locally", func(t *testing.T) { + items := []map[string]any{ + {"reportId": "report-1", "user": map[string]any{"dingUserId": "user-1", "name": "Known Person"}}, + {"reportId": "report-2", "user": map[string]any{"dingUserId": "user-2", "name": "Another Person"}}, + } + if got := filterAgoalSubmissions(items, "known"); len(got) != 1 { + t.Fatalf("known submission projection = %#v", got) + } + if got := filterAgoalSubmissions(items, "guaranteed-zero"); len(got) != 0 { + t.Fatalf("zero submission projection = %#v", got) + } + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": map[string]any{ + "page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, + "result": []any{items[0], items[1]}, + }}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "guaranteed-zero", "--page", "1", "--page-size", "20"); err != nil { + t.Fatal(err) + } + want := []agoalCoverageCall{{tool: "get_submit_detail", params: map[string]any{ + "templateId": "template", "submitState": "NOT_SUBMITTED", "page": 1, "pageSize": agoalSubmissionReadPageSize, + }}} + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("report keyword scan rejects duplicate pages", func(t *testing.T) { + item := map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}} + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": { + {"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}}, + {"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{item}}}, + }, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil { + t.Fatal("duplicate submission pagination was accepted") + } + }) + + t.Run("report keyword scan rejects changing total", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": { + {"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}}, + {"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 3, "result": []any{map[string]any{"reportId": "report-2", "user": map[string]any{"id": "user-2", "name": "Known"}}}}}, + }, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil { + t.Fatal("changing pagination total was accepted") + } + }) + + t.Run("report keyword scan rejects stalled page", func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": { + {"success": true, "content": map[string]any{"page": 1, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{map[string]any{"reportId": "report-1", "user": map[string]any{"id": "user-1", "name": "Known"}}}}}, + {"success": true, "content": map[string]any{"page": 2, "pageSize": agoalSubmissionReadPageSize, "totalCount": 2, "result": []any{}}}, + }, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "NOT_SUBMITTED", "--keyword", "known"); err == nil { + t.Fatal("stalled pagination was accepted") + } + }) + + t.Run("report projection drops unreviewed personnel fields", func(t *testing.T) { + items, err := requireAgoalSubmissionList([]any{map[string]any{ + "reportId": "report", "unreviewed": "drop", + "user": map[string]any{"id": "user", "name": "Known", "email": "drop"}, + }}) + if err != nil { + t.Fatal(err) + } + if _, found := items[0]["unreviewed"]; found { + t.Fatal("unreviewed submission field was projected") + } + user := items[0]["user"].(map[string]any) + if _, found := user["email"]; found { + t.Fatal("unreviewed personnel field was projected") + } + }) + + for _, tc := range []struct { + name string + content map[string]any + }{ + {name: "missing stable user", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 1, "result": []any{map[string]any{"user": map[string]any{"name": "x"}}}}}, + {name: "page mismatch", content: map[string]any{"page": 2, "pageSize": 20, "totalCount": 0, "result": []any{}}}, + {name: "impossible total", content: map[string]any{"page": 1, "pageSize": 20, "totalCount": 0, "result": []any{map[string]any{"user": map[string]any{"id": "user"}}}}}, + } { + t.Run("report rejects "+tc.name, func(t *testing.T) { + caller := &agoalCoverageCaller{responses: map[string][]map[string]any{ + "get_submit_detail": {{"success": true, "content": tc.content}}, + }} + if err := runAgoalCoverage(t, ReportSubmitDetail, + caller, "--template-id", "template", "--submit-state", "ON_TIME", "--page", "1", "--page-size", "20"); err == nil { + t.Fatal("malformed submission page was accepted") + } + }) + } +} + +func TestCrossPlatformCoverageAgoalInvalidInputMakesZeroCalls(t *testing.T) { + for _, args := range [][]string{{"--page", "0"}, {"--page-size", "0"}, {"--page-size", "101"}} { + caller := &agoalCoverageCaller{} + err := runAgoalCoverage(t, ObjectTemplateList, caller, args...) + var typed *apperrors.Error + if !errors.As(err, &typed) { + t.Fatalf("invalid args %v error = %#v", args, err) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid args %v called MCP: %#v", args, caller.calls) + } + } + for _, args := range [][]string{ + {"--template-id", "template", "--submit-state", "ON_TIME", "--page", "0"}, + {"--template-id", "template", "--submit-state", "ON_TIME", "--page-size", "101"}, + {"--template-id", "template", "--submit-state", "ON_TIME", "--query-date", "not-a-date"}, + } { + caller := &agoalCoverageCaller{} + if err := runAgoalCoverage(t, ReportSubmitDetail, caller, args...); err == nil { + t.Fatalf("invalid submission args %v were accepted", args) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid submission args %v called MCP: %#v", args, caller.calls) + } + } +} + +func TestCrossPlatformCoverageAgoalReviewedInventory(t *testing.T) { + total := 0 + public := 0 + unavailable := 0 + for _, item := range shortcut.All() { + if item.Service != productAgoal { + continue + } + total++ + if item.Availability == shortcut.AvailabilityUnavailable { + unavailable++ + if !item.Hidden || shortcut.InPublicCatalog(item.Service, item.Command) { + t.Errorf("unavailable %s must remain hidden/non-public", item.Command) + } + continue + } + if shortcut.InPublicCatalog(item.Service, item.Command) { + public++ + if item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability) + } + if item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout) + } + if item.Contract.Result == nil || item.Contract.Interface == nil || strings.TrimSpace(item.Contract.Description) == "" { + t.Errorf("public %s has incomplete Contract/Result", item.Command) + } + if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" { + t.Errorf("public %s has incomplete Safety", item.Command) + } + } + } + if total != 16 || public != 5 || unavailable != 11 { + t.Fatalf("agoal inventory total/public/unavailable = %d/%d/%d, want 16/5/11", total, public, unavailable) + } +} + +func TestCrossPlatformCoverageAgoalUnavailableCommandsMakeZeroCalls(t *testing.T) { + for _, spec := range unavailableAgoalSpecs { + declaration, found := agoalRegistered(spec.command) + if !found { + t.Fatalf("registered unavailable command missing: %s", spec.command) + } + args := make([]string, 0) + for _, flag := range declaration.Flags { + if !flag.Required { + continue + } + args = append(args, "--"+flag.Name) + switch flag.Name { + case "scope-type": + args = append(args, "PERSONAL") + case "tracking-period-type": + args = append(args, "MONTHLY") + case "submit-state": + args = append(args, "ON_TIME") + default: + if flag.Type != shortcut.FlagBool { + args = append(args, "fixture") + } + } + } + caller := &agoalCoverageCaller{} + err := runAgoalCoverage(t, declaration, caller, args...) + if err == nil || !strings.Contains(err.Error(), spec.reason) { + t.Errorf("%s unavailable error = %v", spec.command, err) + } + if len(caller.calls) != 0 { + t.Errorf("%s unavailable command called MCP: %#v", spec.command, caller.calls) + } + } +} diff --git a/internal/shortcut/builtin/builtin.go b/internal/shortcut/builtin/builtin.go index b0ddb2f4..880e0b78 100644 --- a/internal/shortcut/builtin/builtin.go +++ b/internal/shortcut/builtin/builtin.go @@ -25,6 +25,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" // Service packages — each registers its shortcuts from init(). + _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/agoal" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aisearch" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitable" _ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/attendance" diff --git a/internal/shortcut/devapp/devapp.go b/internal/shortcut/devapp/devapp.go index 77519d70..547e634e 100644 --- a/internal/shortcut/devapp/devapp.go +++ b/internal/shortcut/devapp/devapp.go @@ -26,12 +26,13 @@ package devapp import ( "encoding/json" + "fmt" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -64,6 +65,29 @@ func devAppObjectResult(outcomes ...contract.ResultOutcome) *contract.ResultSpec } } +func devAppVerifiedMutationResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"经稳定应用 ID 精确读回验证的开放平台应用变更","properties":{"action":{"type":"string","description":"已执行并验证的变更动作"},"unifiedAppId":{"type":"string","description":"发生变更的稳定统一应用 ID"},"verified":{"type":"boolean","description":"是否已通过精确对象读回或删除后全量排除验证"},"resource":{"type":"object","description":"写后按同一稳定 ID 读回的业务对象;删除动作省略","additionalProperties":true}},"required":["action","unifiedAppId","verified"],"additionalProperties":false}`), + } +} + +func devAppCredentialResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"按稳定应用 ID 严格验证的开放平台应用凭证","properties":{"unifiedAppId":{"type":"string","description":"开放平台统一应用 ID"},"appKey":{"type":"string","description":"应用公开客户端标识"},"clientId":{"type":"string","description":"应用公开客户端标识兼容字段"},"appSecret":{"type":"string","description":"应用敏感客户端密钥"},"clientSecret":{"type":"string","description":"应用敏感客户端密钥兼容字段"},"secret":{"type":"string","description":"应用敏感密钥兼容字段"}},"additionalProperties":true}`), + SensitivePaths: []string{"appSecret", "clientSecret", "secret"}, + } +} + +func devAppMemberListResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证并可按稳定 userId 精确筛选的应用成员列表","properties":{"count":{"type":"integer","description":"匹配的成员数量"},"members":{"type":"array","description":"匹配的应用成员","items":{"type":"object","description":"带稳定 userId 的应用成员","additionalProperties":true}}},"required":["count","members"],"additionalProperties":false}`), + SensitivePaths: []string{"members.name"}, + } +} + func devAppPaginatedProjectionResult(collection, description string) *contract.ResultSpec { schema, _ := json.Marshal(map[string]any{ "type": "object", @@ -98,6 +122,298 @@ func devAppCursorPagination() *contract.PaginationSpec { } } +const devAppMaxReadbackPages = 20 + +func devAppResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func devAppContainers(data map[string]any) []map[string]any { + if data == nil { + return nil + } + containers := []map[string]any{data} + for index := 0; index < len(containers) && index < 12; index++ { + for _, key := range []string{"content", "result", "data"} { + if nested, ok := containers[index][key].(map[string]any); ok { + containers = append(containers, nested) + } + } + } + return containers +} + +func devAppErrorValue(value any) bool { + switch typed := value.(type) { + case nil: + return false + case bool: + return typed + case float64: + return typed != 0 && typed != 200 + case string: + normalized := strings.ToUpper(strings.TrimSpace(typed)) + return normalized != "" && normalized != "0" && normalized != "200" && normalized != "OK" && normalized != "SUCCESS" + case map[string]any: + return len(typed) > 0 + case []any: + return len(typed) > 0 + default: + return true + } +} + +func devAppFirstString(values map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func requireDevAppSuccess(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, devAppResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明操作成功或结果确实为空") + } + foundSuccess := false + for _, candidate := range devAppContainers(data) { + if value, present := candidate["success"]; present { + foundSuccess = true + success, ok := value.(bool) + if !ok { + return nil, devAppResponseError(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message := devAppFirstString(candidate, "errorMsg", "errorMessage", "message") + if message == "" { + message = "服务明确返回 success=false" + } + return nil, devAppResponseError(operation, "remote_failure", message) + } + } + for _, key := range []string{"error", "errorCode", "error_code"} { + if value, present := candidate[key]; present && devAppErrorValue(value) { + return nil, devAppResponseError(operation, "conflicting_error", fmt.Sprintf("响应 success 与 %s 错误字段冲突", key)) + } + } + } + if !foundSuccess { + return nil, devAppResponseError(operation, "missing_success", "响应缺少 success 布尔终态,无法证明业务成功") + } + return data, nil +} + +func devAppOnlyEnvelopeFields(candidate map[string]any) bool { + for key := range candidate { + switch key { + case "success", "result", "data", "content", "message", "error", "errorCode", "error_code", "code": + default: + return false + } + } + return true +} + +func requireDevAppObject(data map[string]any, operation string) (map[string]any, error) { + data, err := requireDevAppSuccess(data, operation) + if err != nil { + return nil, err + } + containers := devAppContainers(data) + for index := len(containers) - 1; index >= 0; index-- { + candidate := containers[index] + if len(candidate) == 0 || devAppOnlyEnvelopeFields(candidate) { + continue + } + return candidate, nil + } + return nil, devAppResponseError(operation, "missing_business_result", "响应没有可验证的非空业务对象") +} + +func requireDevAppIdentity(object map[string]any, operation string, expected map[string]string) error { + for key, want := range expected { + if want == "" { + continue + } + if got := devAppFirstString(object, key); got == "" { + return devAppResponseError(operation, "readback_id_missing", fmt.Sprintf("读回对象缺少稳定字段 %s", key)) + } else if got != want { + return devAppResponseError(operation, "readback_id_mismatch", fmt.Sprintf("读回对象字段 %s 与请求目标不一致", key)) + } + } + return nil +} + +func requireDevAppFields(object map[string]any, operation string, expected map[string]any) error { + for key, want := range expected { + got, present := object[key] + if !present { + return devAppResponseError(operation, "readback_field_missing", fmt.Sprintf("读回对象缺少请求字段 %s", key)) + } + if fmt.Sprint(got) != fmt.Sprint(want) { + return devAppResponseError(operation, "readback_field_mismatch", fmt.Sprintf("读回对象字段 %s 与请求值不一致", key)) + } + } + return nil +} + +func requireDevAppStringField(object map[string]any, operation, want string, keys ...string) error { + if got := devAppFirstString(object, keys...); got == "" { + return devAppResponseError(operation, "readback_field_missing", fmt.Sprintf("读回对象缺少请求字段 %s", strings.Join(keys, "/"))) + } else if got != want { + return devAppResponseError(operation, "readback_field_mismatch", fmt.Sprintf("读回对象字段 %s 与请求值不一致", strings.Join(keys, "/"))) + } + return nil +} + +func requireDevAppCollection(data map[string]any, operation string, idKeys []string, keys ...string) ([]any, map[string]any, error) { + data, err := requireDevAppSuccess(data, operation) + if err != nil { + return nil, nil, err + } + for _, candidate := range devAppContainers(data) { + for _, key := range keys { + value, present := candidate[key] + if !present { + continue + } + items, ok := value.([]any) + if !ok { + return nil, nil, devAppResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key)) + } + for index, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + return nil, nil, devAppResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是对象", key, index)) + } + if devAppFirstString(item, idKeys...) == "" { + return nil, nil, devAppResponseError(operation, "missing_item_id", fmt.Sprintf("响应 %s[%d] 缺少稳定身份字段", key, index)) + } + } + return items, candidate, nil + } + } + return nil, nil, devAppResponseError(operation, "missing_collection", "成功响应缺少预期集合字段") +} + +func readDevAppObject(rt *shortcut.RuntimeContext, tool string, params map[string]any, expected map[string]string) (map[string]any, map[string]any, error) { + raw, err := rt.CallMCPData(productDevApp, tool, params) + if err != nil { + return nil, nil, err + } + object, err := requireDevAppObject(raw, productDevApp+"/"+tool) + if err != nil { + return nil, nil, err + } + if err := requireDevAppIdentity(object, productDevApp+"/"+tool, expected); err != nil { + return nil, nil, err + } + return raw, object, nil +} + +func outputDevAppObject(rt *shortcut.RuntimeContext, tool string, params map[string]any, expected map[string]string) error { + raw, _, err := readDevAppObject(rt, tool, params, expected) + if err != nil { + return err + } + return rt.OutputForTool(tool, raw) +} + +func devAppWritePreview(rt *shortcut.RuntimeContext, tool string, params map[string]any) (bool, error) { + if !rt.DryRun() { + return false, nil + } + return true, rt.Output(map[string]any{ + "dry_run": true, "executed": false, "tool": tool, "arguments": params, + }) +} + +func callDevAppWrite(rt *shortcut.RuntimeContext, tool string, params map[string]any) (map[string]any, error) { + raw, err := rt.CallMCPWriteDataStrict(productDevApp, tool, params) + if err != nil { + return nil, err + } + return requireDevAppSuccess(raw, productDevApp+"/"+tool) +} + +func verifiedDevAppMutation(action, appID string, resource map[string]any) map[string]any { + result := map[string]any{ + "action": action, "unifiedAppId": appID, "verified": true, + } + if resource != nil { + result["resource"] = resource + } + return result +} + +func verifyDeletedDevApp(rt *shortcut.RuntimeContext, appID, appKey string) error { + if strings.TrimSpace(appKey) == "" { + return devAppResponseError("devapp/delete_dev_app", "missing_readback_selector", "删除前读回缺少 appKey,无法在删除后绑定稳定排除查询") + } + cursor := "" + seen := map[string]bool{} + for page := 0; page < devAppMaxReadbackPages; page++ { + params := map[string]any{"appKey": appKey, "pageSize": 20} + if cursor != "" { + params["cursor"] = cursor + } + raw, err := rt.CallMCPData(productDevApp, "list_dev_app", params) + if err != nil { + return err + } + apps, err := listAppProject(raw) + if err != nil { + return err + } + for _, app := range apps { + if devAppFirstString(app, "unifiedAppId") == appID { + return devAppResponseError("devapp/list_dev_app", "delete_readback_present", "删除后精确列表读回仍包含目标应用") + } + } + projection, err := devAppListProjection(raw, "apps", apps, "devapp/list_dev_app") + if err != nil { + return err + } + more, _ := projection["hasMore"].(bool) + if !more { + return nil + } + next := devAppFirstString(projection, "nextCursor") + if next == "" || next == cursor || seen[next] { + return devAppResponseError("devapp/list_dev_app", "cursor_stall", "删除后排除查询的分页游标未推进") + } + seen[next] = true + cursor = next + } + return devAppResponseError("devapp/list_dev_app", "page_limit", "删除后排除查询达到页数上限,无法证明目标已不存在") +} + +func changeDevAppStatus(rt *shortcut.RuntimeContext, tool, action, expectedStatus string) error { + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} + if previewed, err := devAppWritePreview(rt, tool, params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, tool, params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_dev_app", params, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + if err := requireDevAppStringField(resource, "devapp/get_dev_app", expectedStatus, "appStatus", "status"); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation(action, appID, resource)) +} + // --------------------------------------------------------------------------- // 应用主体 // --------------------------------------------------------------------------- @@ -182,24 +498,31 @@ var ListApp = shortcut.Shortcut{ if err != nil { return err } - apps := listAppProject(data) - return rt.Output(devAppListProjection(data, "apps", apps)) + apps, err := listAppProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "apps", apps, "devapp/list_dev_app") + if err != nil { + return err + } + return rt.Output(projection) }, } // listAppProject reshapes list_dev_app into a clean app list // ({unifiedAppId, name, appKey, agentId, status, gmtModified}) — output-projection -// clean output projection. The list container and per-item field names are probed -// defensively across candidate keys, so an unknown/empty shape yields an empty -// list rather than a crash or fabricated data. -func listAppProject(data map[string]any) []map[string]any { - raw := listAppFindList(data) +// clean output projection. The list container and every item are validated +// before projection so unknown response shapes fail closed. +func listAppProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app", + []string{"unifiedAppId", "unified_app_id"}, "list", "items", "apps", "appList") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := listAppFirst(m, "unifiedAppId", "unified_app_id"); ok { row["unifiedAppId"] = v @@ -219,36 +542,9 @@ func listAppProject(data map[string]any) []map[string]any { if v, ok := listAppFirst(m, "gmtModified", "gmt_modified", "modifyTime", "modified_time"); ok { row["gmtModified"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// listAppFindList locates the app list payload, tolerating a bare top-level -// array or nesting one level under a common envelope key. -func listAppFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "apps", "appList", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "apps", "appList", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // listAppFirst returns the first present candidate key's value. @@ -302,7 +598,8 @@ var GetApp = shortcut.Shortcut{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + return outputDevAppObject(rt, "get_dev_app", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) }, } @@ -338,6 +635,7 @@ var CreateApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +create --name "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "name", Type: shortcut.FlagString, Desc: "应用名称", Required: true}, @@ -352,7 +650,39 @@ var CreateApp = shortcut.Shortcut{ if rt.Changed("icon-media-id") { params["iconMediaId"] = rt.Str("icon-media-id") } - return rt.CallMCP("create_dev_app", params) + if previewed, err := devAppWritePreview(rt, "create_dev_app", params); previewed || err != nil { + return err + } + receipt, err := callDevAppWrite(rt, "create_dev_app", params) + if err != nil { + return err + } + created, err := requireDevAppObject(receipt, "devapp/create_dev_app") + if err != nil { + return err + } + appID := devAppFirstString(created, "unifiedAppId", "unified_app_id") + if appID == "" { + return devAppResponseError("devapp/create_dev_app", "missing_resource_id", "创建回执缺少 unifiedAppId,无法绑定精确读回") + } + _, resource, err := readDevAppObject(rt, "get_dev_app", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str("name"), "name", "appName"); err != nil { + return err + } + if rt.Changed("desc") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str("desc"), "desc", "description"); err != nil { + return err + } + } + if rt.Changed("icon-media-id") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str("icon-media-id"), "iconMediaId", "icon"); err != nil { + return err + } + } + return rt.Output(verifiedDevAppMutation("create", appID, resource)) }, } @@ -388,15 +718,30 @@ var UpdateApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +update --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "name", Type: shortcut.FlagString, Desc: "新的应用名称"}, - {Name: "desc", Type: shortcut.FlagString, Desc: "新的应用描述"}, - {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "新的应用图标 mediaId"}, + {Name: "name", Type: shortcut.FlagString, Desc: "新的应用名称;至少提供一项非空的应用基础信息更新"}, + {Name: "desc", Type: shortcut.FlagString, Desc: "新的应用描述;至少提供一项非空的应用基础信息更新"}, + {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "新的应用图标 mediaId;至少提供一项非空的应用基础信息更新"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"name", "desc", "icon-media-id"}, + Description: "至少提供一项非空的应用基础信息更新", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + for _, name := range []string{"name", "desc", "icon-media-id"} { + if rt.Changed(name) && rt.Str(name) != "" { + return nil + } + } + return apperrors.NewValidation("至少提供一项待更新字段:--name、--desc 或 --icon-media-id") }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("name") { params["name"] = rt.Str("name") } @@ -406,7 +751,26 @@ var UpdateApp = shortcut.Shortcut{ if rt.Changed("icon-media-id") { params["iconMediaId"] = rt.Str("icon-media-id") } - return rt.CallMCP("update_dev_app", params) + if previewed, err := devAppWritePreview(rt, "update_dev_app", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "update_dev_app", params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_dev_app", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + for flag, keys := range map[string][]string{ + "name": {"name", "appName"}, "desc": {"desc", "description"}, "icon-media-id": {"iconMediaId", "icon"}, + } { + if rt.Changed(flag) { + if err := requireDevAppStringField(resource, "devapp/get_dev_app", rt.Str(flag), keys...); err != nil { + return err + } + } + } + return rt.Output(verifiedDevAppMutation("update", appID, resource)) }, } @@ -442,12 +806,32 @@ var DeleteApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +delete --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("delete_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} + if previewed, err := devAppWritePreview(rt, "delete_dev_app", params); previewed || err != nil { + return err + } + _, existing, err := readDevAppObject(rt, "get_dev_app", params, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + appKey := devAppFirstString(existing, "appKey", "clientId") + if appKey == "" { + return devAppResponseError("devapp/get_dev_app", "missing_readback_selector", "删除前读回缺少 appKey,拒绝进入不可逆写入") + } + if _, err := callDevAppWrite(rt, "delete_dev_app", params); err != nil { + return err + } + if err := verifyDeletedDevApp(rt, appID, appKey); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("delete", appID, nil)) }, } @@ -483,12 +867,13 @@ var EnableApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +enable --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("enable_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppStatus(rt, "enable_dev_app", "enable", "normal") }, } @@ -524,12 +909,13 @@ var DisableApp = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +disable --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("disable_dev_app", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppStatus(rt, "disable_dev_app", "disable", "disabled") }, } @@ -541,11 +927,48 @@ var GetCredentials = shortcut.Shortcut{ Description: "读取开放平台应用凭证", Intent: "当你需要拿到某应用的鉴权凭证(如 clientId/AppKey、clientSecret/AppSecret)以便在代码或调试中调用开放平台接口时使用;输入 unifiedAppId,返回该应用的凭证信息。", Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "medium", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", + Name: "shortcut_credentials_get", + CanonicalPath: "devapp.shortcut_credentials_get", + CLIPath: "devapp +credentials-get", + PrimaryCLIPath: "devapp +credentials-get", + }, + Description: "读取开放平台应用凭证", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed adapter requires an explicit success marker, a non-empty credential object, exact unifiedAppId, a public client identifier, and a non-empty secret before unified output; secret fields are declared sensitive.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "读取开放平台应用凭证", + UseWhen: []string{"当你需要拿到某应用的鉴权凭证(如 clientId/AppKey、clientSecret/AppSecret)以便在代码或调试中调用开放平台接口时使用;输入 unifiedAppId,返回该应用的凭证信息。"}, + AvoidWhen: []string{"只需应用元数据时使用 +get;不要把返回密钥写入文档、日志、示例或聊天正文"}, + Examples: []string{"dws devapp +credentials-get --unified-app-id "}, + }, + Result: devAppCredentialResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_dev_app_credentials", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + _, credentials, err := readDevAppObject(rt, "get_dev_app_credentials", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + if devAppFirstString(credentials, "appKey", "clientId") == "" { + return devAppResponseError("devapp/get_dev_app_credentials", "missing_client_id", "凭证响应缺少非空 appKey/clientId") + } + if devAppFirstString(credentials, "appSecret", "clientSecret", "secret") == "" { + return devAppResponseError("devapp/get_dev_app_credentials", "missing_client_secret", "凭证响应缺少非空 appSecret/clientSecret") + } + return rt.Output(credentials) }, } @@ -594,7 +1017,8 @@ var WebappGet = shortcut.Shortcut{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_extension_webapp_config", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + return outputDevAppObject(rt, "get_extension_webapp_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) }, } @@ -630,16 +1054,31 @@ var WebappConfig = shortcut.Shortcut{ AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +webapp-config --unified-app-id "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "h5-page-type", Type: shortcut.FlagString, Desc: "网页应用生效端/页面类型"}, - {Name: "homepage-url", Type: shortcut.FlagString, Desc: "移动端首页地址"}, - {Name: "pc-homepage-url", Type: shortcut.FlagString, Desc: "PC 端首页地址"}, - {Name: "omp-url", Type: shortcut.FlagString, Desc: "管理后台地址"}, + {Name: "h5-page-type", Type: shortcut.FlagString, Desc: "网页应用生效端/页面类型;至少提供一项非空的网页应用配置"}, + {Name: "homepage-url", Type: shortcut.FlagString, Desc: "移动端首页地址;至少提供一项非空的网页应用配置"}, + {Name: "pc-homepage-url", Type: shortcut.FlagString, Desc: "PC 端首页地址;至少提供一项非空的网页应用配置"}, + {Name: "omp-url", Type: shortcut.FlagString, Desc: "管理后台地址;至少提供一项非空的网页应用配置"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"h5-page-type", "homepage-url", "pc-homepage-url", "omp-url"}, + Description: "至少提供一项非空的网页应用配置", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + for _, name := range []string{"h5-page-type", "homepage-url", "pc-homepage-url", "omp-url"} { + if rt.Changed(name) && rt.Str(name) != "" { + return nil + } + } + return apperrors.NewValidation("至少提供一项网页应用配置:--h5-page-type、--homepage-url、--pc-homepage-url 或 --omp-url") }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("h5-page-type") { params["h5PageType"] = rt.Str("h5-page-type") } @@ -652,7 +1091,27 @@ var WebappConfig = shortcut.Shortcut{ if rt.Changed("omp-url") { params["ompUrl"] = rt.Str("omp-url") } - return rt.CallMCP("set_extension_webapp_config", params) + if previewed, err := devAppWritePreview(rt, "set_extension_webapp_config", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "set_extension_webapp_config", params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_extension_webapp_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + for flag, keys := range map[string][]string{ + "h5-page-type": {"h5PageType"}, "homepage-url": {"homepageUrl"}, + "pc-homepage-url": {"pcHomepageUrl"}, "omp-url": {"ompUrl"}, + } { + if rt.Changed(flag) { + if err := requireDevAppStringField(resource, "devapp/get_extension_webapp_config", rt.Str(flag), keys...); err != nil { + return err + } + } + } + return rt.Output(verifiedDevAppMutation("webapp_config", appID, resource)) }, } @@ -725,24 +1184,33 @@ var PermissionList = shortcut.Shortcut{ if err != nil { return err } - permissions := permissionListProject(data) - return rt.Output(devAppListProjection(data, "permissions", permissions)) + permissions, err := permissionListProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "permissions", permissions, "devapp/list_dev_app_permissions") + if err != nil { + return err + } + return rt.Output(projection) }, } // permissionListProject reshapes list_dev_app_permissions into a clean // permission-point list ({scopeValue, scopeName, apiName, authStatus, scopeType}) // — clean output projection. The list container and per-item field -// names are probed defensively across candidate keys, so an unknown/empty shape -// yields an empty list rather than a crash or fabricated data. -func permissionListProject(data map[string]any) []map[string]any { - raw := permissionListFindList(data) +// names are probed across reviewed candidate keys, while missing, mistyped, or +// malformed collections fail closed. +func permissionListProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app_permissions", + []string{"scopeValue", "scope_value", "permissionCode", "code"}, + "list", "items", "permissions", "permissionList", "scopes") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := permissionListFirst(m, "scopeValue", "scope_value", "permissionCode", "code"); ok { row["scopeValue"] = v @@ -759,36 +1227,9 @@ func permissionListProject(data map[string]any) []map[string]any { if v, ok := permissionListFirst(m, "scopeType", "scope_type"); ok { row["scopeType"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// permissionListFindList locates the permission list payload, tolerating a bare -// top-level array or nesting one level under a common envelope key. -func permissionListFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "permissions", "permissionList", "scopes", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "permissions", "permissionList", "scopes", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // permissionListFirst returns the first present candidate key's value. @@ -877,24 +1318,112 @@ var MemberList = shortcut.Shortcut{ AgentSummary: "查询开放平台应用成员", UseWhen: []string{"当你要查看某应用有哪些成员及其角色(如谁是开发者/管理员),用于核对协作人员或权限归属时使用;输入 unifiedAppId,返回成员列表。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws devapp +member-list --unified-app-id "}, + Examples: []string{"dws devapp +member-list --unified-app-id --user-id "}, }, + Result: devAppMemberListResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, + {Name: "user-id", Type: shortcut.FlagString, Desc: "可选稳定 userId;由 Shortcut 在严格验证完整成员数组后做精确等值筛选"}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("list_dev_app_members", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + raw, err := rt.CallMCPData(productDevApp, "list_dev_app_members", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + if err != nil { + return err + } + members, _, err := requireDevAppCollection(raw, "devapp/list_dev_app_members", []string{"userId", "user_id"}, "members", "items", "list") + if err != nil { + return err + } + wantUser := strings.TrimSpace(rt.Str("user-id")) + out := projectDevAppMembers(members, wantUser) + return rt.Output(map[string]any{"count": len(out), "members": out}) }, } +func projectDevAppMembers(members []any, wantUser string) []map[string]any { + out := make([]map[string]any, 0, len(members)) + for _, value := range members { + member := value.(map[string]any) + if wantUser != "" && devAppFirstString(member, "userId", "user_id") != wantUser { + continue + } + out = append(out, member) + } + return out +} + +func readDevAppMembers(rt *shortcut.RuntimeContext, appID string) ([]any, error) { + raw, err := rt.CallMCPData(productDevApp, "list_dev_app_members", map[string]any{"unifiedAppId": appID}) + if err != nil { + return nil, err + } + members, _, err := requireDevAppCollection(raw, "devapp/list_dev_app_members", + []string{"userId", "user_id"}, "members", "items", "list") + return members, err +} + +func verifyDevAppMembers(rt *shortcut.RuntimeContext, appID string, userIDs []string, memberType string, present bool) error { + members, err := readDevAppMembers(rt, appID) + if err != nil { + return err + } + want := make(map[string]bool, len(userIDs)) + for _, userID := range userIDs { + want[userID] = true + } + found := make(map[string]int, len(userIDs)) + for _, value := range members { + member := value.(map[string]any) + userID := devAppFirstString(member, "userId", "user_id") + if !want[userID] { + continue + } + found[userID]++ + if found[userID] > 1 { + return devAppResponseError("devapp/list_dev_app_members", "duplicate_readback_identity", "成员读回包含重复稳定 userId,无法证明唯一终态") + } + if present { + if err := requireDevAppStringField(member, "devapp/list_dev_app_members", memberType, "memberType", "member_type"); err != nil { + return err + } + } + } + for _, userID := range userIDs { + if present && found[userID] != 1 { + return devAppResponseError("devapp/list_dev_app_members", "member_readback_missing", "添加后成员列表未精确包含全部目标 userId") + } + if !present && found[userID] != 0 { + return devAppResponseError("devapp/list_dev_app_members", "member_remove_readback_present", "移除后成员列表仍包含目标 userId") + } + } + return nil +} + +func validatedDevAppMemberInputs(rt *shortcut.RuntimeContext) ([]string, string, error) { + userIDs, err := validatedDevAppValues(rt.StrSlice("user-ids"), "--user-ids") + if err != nil { + return nil, "", err + } + memberType, err := validatedDevAppMemberType(rt.Str("member-type")) + return userIDs, memberType, err +} + +func validatedDevAppMemberType(value string) (string, error) { + memberType := strings.TrimSpace(value) + if memberType == "" { + return "", apperrors.NewValidation("--member-type 不能为空") + } + return memberType, nil +} + // MemberAdd maps helper `add_dev_app_members`. var MemberAdd = shortcut.Shortcut{ Service: "devapp", Command: "+member-add", Product: productDevApp, Description: "添加开放平台应用成员", - Intent: "当你要给某应用增加协作人员(如把某人加为开发者)时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER),会实际把这些人加入应用成员并赋予对应角色。", + Intent: "当你要给某应用增加协作人员并要求按稳定 userId 逐项确认成员角色已写回时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER)。", Risk: shortcut.RiskWrite, Safety: contract.SafetySpec{ Effect: "write", Risk: "medium", @@ -916,23 +1445,45 @@ var MemberAdd = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "添加开放平台应用成员", - UseWhen: []string{"当你要给某应用增加协作人员(如把某人加为开发者)时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER),会实际把这些人加入应用成员并赋予对应角色。"}, + UseWhen: []string{"当你要给某应用增加协作人员并要求按稳定 userId 逐项确认成员角色已写回时使用;传入 unifiedAppId、userId 列表和成员类型(如 DEVELOPER)。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +member-add --unified-app-id --user-ids --member-type "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表", Required: true}, - {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER", Required: true}, + {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表,不能为空且不能重复", Required: true}, + {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER,不能为空", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"user-ids"}, Description: "userId 列表不能为空且不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"member-type"}, Description: "成员类型不能为空"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + _, _, err := validatedDevAppMemberInputs(rt) + return err }, Execute: func(rt *shortcut.RuntimeContext) error { + userIDs, memberType, _ := validatedDevAppMemberInputs(rt) + appID := rt.Str("unified-app-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "userIds": rt.StrSlice("user-ids"), - "memberType": rt.Str("member-type"), + "unifiedAppId": appID, + "userIds": userIDs, + "memberType": memberType, } - return rt.CallMCP("add_dev_app_members", params) + if previewed, err := devAppWritePreview(rt, "add_dev_app_members", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "add_dev_app_members", params); err != nil { + return err + } + if err := verifyDevAppMembers(rt, appID, userIDs, memberType, true); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("member_add", appID, map[string]any{ + "count": len(userIDs), "memberType": memberType, + })) }, } @@ -942,7 +1493,7 @@ var MemberRemove = shortcut.Shortcut{ Command: "+member-remove", Product: productDevApp, Description: "移除开放平台应用成员", - Intent: "当某人离职或不再参与、你要取消其对应用的访问/协作权限时使用;传入 unifiedAppId、userId 列表和成员类型,会实际把这些人从应用成员中移除。", + Intent: "当某人不再参与、你要取消其应用成员身份,并要求按稳定 userId 逐项确认目标已不在成员列表中时使用;传入 unifiedAppId、userId 列表和成员类型。", Risk: shortcut.RiskHighWrite, Safety: contract.SafetySpec{ Effect: "destructive", Risk: "high", @@ -964,23 +1515,45 @@ var MemberRemove = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "移除开放平台应用成员", - UseWhen: []string{"当某人离职或不再参与、你要取消其对应用的访问/协作权限时使用;传入 unifiedAppId、userId 列表和成员类型,会实际把这些人从应用成员中移除。"}, + UseWhen: []string{"当某人不再参与、你要取消其应用成员身份,并要求按稳定 userId 逐项确认目标已不在成员列表中时使用;传入 unifiedAppId、userId 列表和成员类型。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +member-remove --unified-app-id --user-ids --member-type "}, }, + Result: devAppVerifiedMutationResult(), }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表", Required: true}, - {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER", Required: true}, + {Name: "user-ids", Type: shortcut.FlagStringSlice, Desc: "成员 userId 列表,不能为空且不能重复", Required: true}, + {Name: "member-type", Type: shortcut.FlagString, Desc: "成员类型,如 DEVELOPER,不能为空", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"user-ids"}, Description: "userId 列表不能为空且不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"member-type"}, Description: "成员类型不能为空"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + _, _, err := validatedDevAppMemberInputs(rt) + return err }, Execute: func(rt *shortcut.RuntimeContext) error { + userIDs, memberType, _ := validatedDevAppMemberInputs(rt) + appID := rt.Str("unified-app-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "userIds": rt.StrSlice("user-ids"), - "memberType": rt.Str("member-type"), + "unifiedAppId": appID, + "userIds": userIDs, + "memberType": memberType, } - return rt.CallMCP("remove_dev_app_members", params) + if previewed, err := devAppWritePreview(rt, "remove_dev_app_members", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "remove_dev_app_members", params); err != nil { + return err + } + if err := verifyDevAppMembers(rt, appID, userIDs, memberType, false); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("member_remove", appID, map[string]any{ + "count": len(userIDs), "memberType": memberType, + })) }, } @@ -1062,7 +1635,8 @@ var RobotGet = shortcut.Shortcut{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_extension_robot_config", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + appID := rt.Str("unified-app-id") + return outputDevAppObject(rt, "get_extension_robot_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) }, } @@ -1075,21 +1649,63 @@ var RobotConfig = shortcut.Shortcut{ Description: "创建或更新现有应用的机器人配置(upsert)", Intent: "当你要为应用开通机器人或调整其机器人设置(改名称/简介/图标、设消息与事件回调地址、切换 HTTPS/STREAM/AISKILL 模式、配技能、是否自动加权限或关 SSL 校验)时使用;按 unifiedAppId 以 upsert 方式写入机器人配置,会实际生效。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "unknown", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_robot_config", + CanonicalPath: "devapp.shortcut_robot_config", CLIPath: "devapp +robot-config", PrimaryCLIPath: "devapp +robot-config", + }, + Description: "创建或更新现有应用的机器人配置并精确读回", + Interface: &contract.InterfaceSpec{ + Mode: "composite", Availability: "available", + Reason: "Reviewed adapter rejects an empty update, requires terminal success, then reads the same unifiedAppId and compares every changed scalar field before returning verified output.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "创建或更新现有应用的机器人配置(upsert)", + UseWhen: []string{"当你要为应用开通机器人或调整其机器人设置(改名称/简介/图标、设消息与事件回调地址、切换 HTTPS/STREAM/AISKILL 模式、配技能、是否自动加权限或关 SSL 校验)时使用;按 unifiedAppId 以 upsert 方式写入机器人配置,会实际生效。"}, + AvoidWhen: []string{"只需查看现状使用 +robot-get;仅切换已配置机器人的在线状态使用 +robot-enable 或 +robot-disable"}, + Examples: []string{"dws devapp +robot-config --unified-app-id --name --mode STREAM"}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "name", Type: shortcut.FlagString, Desc: "机器人名称"}, - {Name: "brief", Type: shortcut.FlagString, Desc: "机器人简介"}, - {Name: "desc", Type: shortcut.FlagString, Desc: "机器人描述"}, - {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "机器人图标 mediaId"}, - {Name: "outgoing-url", Type: shortcut.FlagString, Desc: "消息回调地址"}, - {Name: "event-callback-url", Type: shortcut.FlagString, Desc: "事件回调地址"}, - {Name: "mode", Type: shortcut.FlagString, Enum: []string{"HTTPS", "STREAM", "AISKILL"}, Desc: "机器人模式:HTTPS / STREAM / AISKILL"}, - {Name: "skills", Type: shortcut.FlagStringSlice, Desc: "技能列表"}, - {Name: "add-scope", Type: shortcut.FlagBool, Desc: "是否自动添加机器人相关权限"}, - {Name: "disable-ssl-verify", Type: shortcut.FlagBool, Desc: "回调地址是否关闭 SSL 校验"}, + {Name: "name", Type: shortcut.FlagString, Desc: "机器人名称;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "brief", Type: shortcut.FlagString, Desc: "机器人简介;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "desc", Type: shortcut.FlagString, Desc: "机器人描述;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "icon-media-id", Type: shortcut.FlagString, Desc: "机器人图标 mediaId;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "outgoing-url", Type: shortcut.FlagString, Desc: "消息回调地址;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "event-callback-url", Type: shortcut.FlagString, Desc: "事件回调地址;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "mode", Type: shortcut.FlagString, Enum: []string{"HTTPS", "STREAM", "AISKILL"}, Desc: "机器人模式:HTTPS / STREAM / AISKILL;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "skills", Type: shortcut.FlagStringSlice, Desc: "技能列表;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "add-scope", Type: shortcut.FlagBool, Desc: "是否自动添加机器人相关权限;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + {Name: "disable-ssl-verify", Type: shortcut.FlagBool, Desc: "回调地址是否关闭 SSL 校验;至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"name", "brief", "desc", "icon-media-id", "outgoing-url", "event-callback-url", "mode", "skills", "add-scope", "disable-ssl-verify"}, + Description: "至少提供一项非空机器人配置;布尔开关显式传入 false 也算配置", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + for _, name := range []string{"name", "brief", "desc", "icon-media-id", "outgoing-url", "event-callback-url", "mode"} { + if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) != "" { + return nil + } + } + if rt.Changed("skills") && len(rt.StrSlice("skills")) > 0 { + return nil + } + if rt.Changed("add-scope") || rt.Changed("disable-ssl-verify") { + return nil + } + return apperrors.NewValidation("至少提供一项非空机器人配置") }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("name") { params["name"] = rt.Str("name") } @@ -1120,7 +1736,45 @@ var RobotConfig = shortcut.Shortcut{ if rt.Changed("disable-ssl-verify") { params["disableSSLVerify"] = rt.Bool("disable-ssl-verify") } - return rt.CallMCP("set_extension_robot_config", params) + if previewed, err := devAppWritePreview(rt, "set_extension_robot_config", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "set_extension_robot_config", params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_extension_robot_config", map[string]any{"unifiedAppId": appID}, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + for flag, keys := range map[string][]string{ + "name": {"name"}, "brief": {"brief"}, "desc": {"desc", "description"}, + "icon-media-id": {"iconMediaId"}, "outgoing-url": {"outgoingUrl"}, + "event-callback-url": {"eventCallbackUrl"}, "mode": {"mode"}, + } { + if !rt.Changed(flag) { + continue + } + want := rt.Str(flag) + if flag == "mode" { + want = strings.ToUpper(want) + } + if err := requireDevAppStringField(resource, "devapp/get_extension_robot_config", want, keys...); err != nil { + return err + } + } + for flag, key := range map[string]string{"add-scope": "addScope", "disable-ssl-verify": "disableSSLVerify"} { + if rt.Changed(flag) { + if err := requireDevAppFields(resource, "devapp/get_extension_robot_config", map[string]any{key: rt.Bool(flag)}); err != nil { + return err + } + } + } + if rt.Changed("skills") { + if err := requireDevAppFields(resource, "devapp/get_extension_robot_config", map[string]any{"skills": rt.StrSlice("skills")}); err != nil { + return err + } + } + return rt.Output(verifiedDevAppMutation("robot_config", appID, resource)) }, } @@ -1130,13 +1784,32 @@ var RobotEnable = shortcut.Shortcut{ Command: "+robot-enable", Product: productDevApp, Description: "启用现有应用机器人能力(纯启用,无需配置字段)", - Intent: "当应用的机器人能力已配置但处于关闭状态、你只想把它打开生效时使用;仅传 unifiedAppId 即可,会实际启用该应用的机器人能力,无需再传配置字段。", + Intent: "当应用已经具有可启用的机器人配置、你要把它切换到 ONLINE 时使用;写后会读取同一 unifiedAppId 并只在 robotStatus=ONLINE 时成功。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_robot_enable", + CanonicalPath: "devapp.shortcut_robot_enable", CLIPath: "devapp +robot-enable", PrimaryCLIPath: "devapp +robot-enable", + }, + Description: "启用已配置的应用机器人并读回 ONLINE 终态", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter requires terminal success and exact get_extension_robot_config readback for the same unifiedAppId with robotStatus=ONLINE."}, + Selection: contract.SelectionSpec{ + AgentSummary: "启用现有应用机器人能力(纯启用,无需配置字段)", + UseWhen: []string{"当应用已经具有可启用的机器人配置、你要把它切换到 ONLINE 时使用;写后会读取同一 unifiedAppId 并只在 robotStatus=ONLINE 时成功。"}, + AvoidWhen: []string{"需要修改机器人字段时使用 +robot-config;只查看状态使用 +robot-get"}, + Examples: []string{"dws devapp +robot-enable --unified-app-id "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("enable_dev_app_robot", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppRobotStatus(rt, "enable_dev_app_robot", "robot_enable", "ONLINE") }, } @@ -1146,16 +1819,58 @@ var RobotDisable = shortcut.Shortcut{ Command: "+robot-disable", Product: productDevApp, Description: "停用现有应用的机器人能力", - Intent: "当你要临时关闭某应用的机器人(不再收发机器人消息)但保留其配置时使用;传入 unifiedAppId 会实际停用机器人能力,可日后再启用恢复。", + Intent: "当你要下线某应用的机器人、不再收发机器人消息时使用;当前平台读回终态为 UNCONFIGURED,因此重新上线前可能需要先用 +robot-config 恢复配置。", Risk: shortcut.RiskHighWrite, + Safety: contract.SafetySpec{ + Effect: "destructive", Risk: "high", + Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_robot_disable", + CanonicalPath: "devapp.shortcut_robot_disable", CLIPath: "devapp +robot-disable", PrimaryCLIPath: "devapp +robot-disable", + }, + Description: "下线应用机器人并读回 UNCONFIGURED 终态", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter requires terminal success and exact get_extension_robot_config readback for the same unifiedAppId with robotStatus=UNCONFIGURED; it does not promise that configuration remains reusable."}, + Selection: contract.SelectionSpec{ + AgentSummary: "停用现有应用的机器人能力", + UseWhen: []string{"当你要下线某应用的机器人、不再收发机器人消息时使用;当前平台读回终态为 UNCONFIGURED,因此重新上线前可能需要先用 +robot-config 恢复配置。"}, + AvoidWhen: []string{"只需临时停用但必须保证配置可原样恢复时不要使用;当前下游没有该保留语义"}, + Examples: []string{"dws devapp +robot-disable --unified-app-id "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("disable_dev_app_robot", map[string]any{"unifiedAppId": rt.Str("unified-app-id")}) + return changeDevAppRobotStatus(rt, "disable_dev_app_robot", "robot_disable", "UNCONFIGURED") }, } +func changeDevAppRobotStatus(rt *shortcut.RuntimeContext, tool, action, wantStatus string) error { + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} + if previewed, err := devAppWritePreview(rt, tool, params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, tool, params); err != nil { + return err + } + _, resource, err := readDevAppObject(rt, "get_extension_robot_config", params, map[string]string{"unifiedAppId": appID}) + if err != nil { + return err + } + gotStatus := strings.ToUpper(devAppFirstString(resource, "robotStatus", "robot_status", "status")) + if gotStatus == "" { + return devAppResponseError("devapp/get_extension_robot_config", "readback_status_missing", "机器人状态读回缺少 robotStatus") + } + if gotStatus != wantStatus { + return devAppResponseError("devapp/get_extension_robot_config", "readback_status_mismatch", fmt.Sprintf("机器人状态读回为 %s,预期 %s", gotStatus, wantStatus)) + } + return rt.Output(verifiedDevAppMutation(action, appID, resource)) +} + // --------------------------------------------------------------------------- // 事件订阅 // --------------------------------------------------------------------------- @@ -1165,8 +1880,8 @@ var EventList = shortcut.Shortcut{ Service: "devapp", Command: "+event-list", Product: productDevApp, - Description: "查询应用已订阅的事件列表", - Intent: "当你要确认某应用当前订阅了哪些事件回调(用于排查漏收事件、或退订前先查事件码)时使用;输入 unifiedAppId,可按事件码/名称关键词过滤并分页,返回已订阅事件列表。", + Description: "查询应用可用事件目录与订阅状态", + Intent: "当你要查某应用可用的事件码、事件名称及当前订阅状态(用于选择订阅项、排查漏收事件或退订前核对)时使用;输入 unifiedAppId,可按关键词过滤并游标分页。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -1180,15 +1895,15 @@ var EventList = shortcut.Shortcut{ CLIPath: "devapp +event-list", PrimaryCLIPath: "devapp +event-list", }, - Description: "查询应用已订阅的事件列表", + Description: "查询应用可用事件目录与订阅状态", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", }, Selection: contract.SelectionSpec{ - AgentSummary: "查询应用已订阅的事件列表", - UseWhen: []string{"当你要确认某应用当前订阅了哪些事件回调(用于排查漏收事件、或退订前先查事件码)时使用;输入 unifiedAppId,可按事件码/名称关键词过滤并分页,返回已订阅事件列表。"}, + AgentSummary: "查询应用可用事件目录与订阅状态", + UseWhen: []string{"当你要查某应用可用的事件码、事件名称及当前订阅状态(用于选择订阅项、排查漏收事件或退订前核对)时使用;输入 unifiedAppId,可按关键词过滤并游标分页。"}, AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, Examples: []string{"dws devapp +event-list --unified-app-id "}, }, @@ -1209,24 +1924,31 @@ var EventList = shortcut.Shortcut{ if err != nil { return err } - events := eventListProject(data) - return rt.Output(devAppListProjection(data, "events", events)) + events, err := eventListProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "events", events, "devapp/list_dev_app_events") + if err != nil { + return err + } + return rt.Output(projection) }, } // eventListProject reshapes list_dev_app_events into a clean subscribed-event // list ({eventCode, eventName, status, gmtModified}) — output-projection -// clean output projection. The list container and per-item field names are probed -// defensively across candidate keys, so an unknown/empty shape yields an empty -// list rather than a crash or fabricated data. -func eventListProject(data map[string]any) []map[string]any { - raw := eventListFindList(data) +// clean output projection. The list container and every item are validated +// before projection so unknown response shapes fail closed. +func eventListProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app_events", + []string{"eventCode", "event_code", "code"}, "list", "items", "events", "eventList") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := eventListFirst(m, "eventCode", "event_code", "code"); ok { row["eventCode"] = v @@ -1240,36 +1962,9 @@ func eventListProject(data map[string]any) []map[string]any { if v, ok := eventListFirst(m, "gmtModified", "gmt_modified", "modifyTime", "modified_time"); ok { row["gmtModified"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// eventListFindList locates the event list payload, tolerating a bare top-level -// array or nesting one level under a common envelope key. -func eventListFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "events", "eventList", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "events", "eventList", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // eventListFirst returns the first present candidate key's value. @@ -1290,19 +1985,127 @@ var EventSubscribe = shortcut.Shortcut{ Description: "订阅应用事件回调", Intent: "当你要让应用开始接收某些事件的回调推送(如通讯录变更、审批事件等)时使用;传入 unifiedAppId 和事件码列表,会实际为该应用登记这些事件订阅。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_event_subscribe", + CanonicalPath: "devapp.shortcut_event_subscribe", CLIPath: "devapp +event-subscribe", PrimaryCLIPath: "devapp +event-subscribe", + }, + Description: "订阅应用事件并逐项精确读回", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter rejects empty or duplicate event codes, requires terminal success, then traverses the bounded event cursor and verifies every requested eventCode under the same unifiedAppId."}, + Selection: contract.SelectionSpec{ + AgentSummary: "订阅应用事件回调", + UseWhen: []string{"当你要让应用开始接收某些事件的回调推送(如通讯录变更、审批事件等)时使用;传入 unifiedAppId 和事件码列表,会实际为该应用登记这些事件订阅。"}, + AvoidWhen: []string{"只需查看现有订阅时暂用 dev app event list 原子命令;+event-list 缺少零结果分页终止事实,退订也无法证明实际移除"}, + Examples: []string{"dws devapp +event-subscribe --unified-app-id --event-codes "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, - {Name: "event-codes", Type: shortcut.FlagStringSlice, Desc: "事件码列表", Required: true}, + {Name: "event-codes", Type: shortcut.FlagStringSlice, Desc: "事件码列表至少包含一项非空且互不重复的 eventCode", Required: true}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, + Flags: []string{"event-codes"}, + Description: "事件码列表至少包含一项非空且互不重复的 eventCode", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + _, err := validatedDevAppValues(rt.StrSlice("event-codes"), "--event-codes") + return err }, Execute: func(rt *shortcut.RuntimeContext) error { + // Validate already rejects empty and duplicate values before Execute. + codes, _ := validatedDevAppValues(rt.StrSlice("event-codes"), "--event-codes") + appID := rt.Str("unified-app-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "eventCodes": rt.StrSlice("event-codes"), + "unifiedAppId": appID, + "eventCodes": codes, } - return rt.CallMCP("subscribe_dev_app_events", params) + if previewed, err := devAppWritePreview(rt, "subscribe_dev_app_events", params); previewed || err != nil { + return err + } + if _, err := callDevAppWrite(rt, "subscribe_dev_app_events", params); err != nil { + return err + } + if err := verifyDevAppEventCodes(rt, appID, codes); err != nil { + return err + } + return rt.Output(verifiedDevAppMutation("event_subscribe", appID, map[string]any{"eventCodes": codes})) }, } +func validatedDevAppValues(values []string, flag string) ([]string, error) { + out := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, raw := range values { + value := strings.TrimSpace(raw) + if value == "" { + return nil, apperrors.NewValidation(flag + " 不能包含空值") + } + if seen[value] { + return nil, apperrors.NewValidation(flag + " 不能包含重复值") + } + seen[value] = true + out = append(out, value) + } + if len(out) == 0 { + return nil, apperrors.NewValidation(flag + " 至少需要一个非空值") + } + return out, nil +} + +func verifyDevAppEventCodes(rt *shortcut.RuntimeContext, appID string, wantCodes []string) error { + want := make(map[string]bool, len(wantCodes)) + for _, code := range wantCodes { + want[code] = true + } + found := map[string]bool{} + cursor := "" + seenCursors := map[string]bool{} + for page := 0; page < devAppMaxReadbackPages; page++ { + params := map[string]any{"unifiedAppId": appID, "pageSize": 100} + if cursor != "" { + params["cursor"] = cursor + } + raw, err := rt.CallMCPData(productDevApp, "list_dev_app_events", params) + if err != nil { + return err + } + events, err := eventListProject(raw) + if err != nil { + return err + } + for _, event := range events { + code := devAppFirstString(event, "eventCode") + if want[code] { + found[code] = true + } + } + if len(found) == len(want) { + return nil + } + projection, err := devAppListProjection(raw, "events", events, "devapp/list_dev_app_events") + if err != nil { + return err + } + more, _ := projection["hasMore"].(bool) + if !more { + return devAppResponseError("devapp/list_dev_app_events", "subscription_readback_missing", "事件订阅终态读回缺少请求的 eventCode") + } + next := devAppFirstString(projection, "nextCursor") + if next == "" || next == cursor || seenCursors[next] { + return devAppResponseError("devapp/list_dev_app_events", "cursor_stall", "事件订阅读回游标未推进") + } + seenCursors[next] = true + cursor = next + } + return devAppResponseError("devapp/list_dev_app_events", "readback_page_limit", "事件订阅读回超过有界页数,无法证明全部事件已订阅") +} + // EventUnsubscribe maps helper `unsubscribe_dev_app_events`. var EventUnsubscribe = shortcut.Shortcut{ Service: "devapp", @@ -1336,20 +2139,72 @@ var VersionCreate = shortcut.Shortcut{ Description: "基于当前配置创建应用新版本", Intent: "当你改完应用配置、准备走发布流程前需要先打一个版本快照时使用;传入 unifiedAppId(可选显式版本号与描述,默认服务端自动递增),会实际创建一个新版本并返回 versionId 供后续预检和发布。", Risk: shortcut.RiskWrite, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "medium", + Confirmation: "user_required", Idempotency: "non_idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "devapp", Name: "shortcut_version_create", + CanonicalPath: "devapp.shortcut_version_create", CLIPath: "devapp +version-create", PrimaryCLIPath: "devapp +version-create", + }, + Description: "创建不可变版本快照并按应用与版本双 ID 精确读回", + Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "Reviewed adapter requires terminal success and a non-empty versionId, then reads get_dev_app_version_detail with the same unifiedAppId/versionId and compares supplied version metadata before verified output."}, + Selection: contract.SelectionSpec{ + AgentSummary: "基于当前配置创建应用新版本", + UseWhen: []string{"当你改完应用配置、准备走发布流程前需要先打一个版本快照时使用;传入 unifiedAppId(可选显式版本号与描述,默认服务端自动递增),会实际创建一个新版本并返回 versionId 供后续预检和发布。"}, + AvoidWhen: []string{"只需查看历史版本使用 +version-list;正式发布使用前先运行 +version-check-approval,发布能力当前仍不可用"}, + Examples: []string{"dws devapp +version-create --unified-app-id --desc "}, + }, + Result: devAppVerifiedMutationResult(), + }, Flags: []shortcut.Flag{ {Name: "unified-app-id", Type: shortcut.FlagString, Desc: "开放平台统一应用 ID", Required: true}, {Name: "version", Type: shortcut.FlagString, Desc: "高级可选:显式版本号,如 1.0.1;默认由服务端自动递增"}, {Name: "desc", Type: shortcut.FlagString, Desc: "版本描述"}, }, Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{"unifiedAppId": rt.Str("unified-app-id")} + appID := rt.Str("unified-app-id") + params := map[string]any{"unifiedAppId": appID} if rt.Changed("version") { params["version"] = rt.Str("version") } if rt.Changed("desc") { params["desc"] = rt.Str("desc") } - return rt.CallMCP("create_dev_app_version", params) + if previewed, err := devAppWritePreview(rt, "create_dev_app_version", params); previewed || err != nil { + return err + } + created, err := callDevAppWrite(rt, "create_dev_app_version", params) + if err != nil { + return err + } + createdObject, err := requireDevAppObject(created, "devapp/create_dev_app_version") + if err != nil { + return err + } + versionID := devAppFirstString(createdObject, "versionId", "version_id", "id") + if versionID == "" { + return devAppResponseError("devapp/create_dev_app_version", "missing_version_id", "创建版本回执缺少稳定 versionId") + } + _, resource, err := readDevAppObject(rt, "get_dev_app_version_detail", map[string]any{ + "unifiedAppId": appID, + "versionId": versionID, + }, map[string]string{"unifiedAppId": appID, "versionId": versionID}) + if err != nil { + return err + } + if rt.Changed("version") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app_version_detail", rt.Str("version"), "version", "versionName"); err != nil { + return err + } + } + if rt.Changed("desc") { + if err := requireDevAppStringField(resource, "devapp/get_dev_app_version_detail", rt.Str("desc"), "desc", "description", "remark"); err != nil { + return err + } + } + return rt.Output(verifiedDevAppMutation("version_create", appID, resource)) }, } @@ -1398,61 +2253,76 @@ var VersionList = shortcut.Shortcut{ if err != nil { return err } - versions := versionListProject(data) - return rt.Output(devAppListProjection(data, "versions", versions)) + versions, err := versionListProject(data) + if err != nil { + return err + } + projection, err := devAppListProjection(data, "versions", versions, "devapp/list_dev_app_versions") + if err != nil { + return err + } + return rt.Output(projection) }, } -func devAppListProjection(data map[string]any, key string, items []map[string]any) map[string]any { +func devAppListProjection(data map[string]any, key string, items []map[string]any, operation string) (map[string]any, error) { + if _, err := requireDevAppSuccess(data, operation); err != nil { + return nil, err + } out := map[string]any{"count": len(items), key: items} for _, candidate := range devAppPaginationCandidates(data) { - _, hasMore := candidate["hasMore"] - _, hasCursor := candidate["nextCursor"] + rawHasMore, hasMore := candidate["hasMore"] + rawCursor, hasCursor := candidate["nextCursor"] if !hasMore && !hasCursor { continue } - if hasMore { - out["hasMore"] = candidate["hasMore"] + if !hasMore { + return nil, devAppResponseError(operation, "missing_has_more", "分页响应只有 nextCursor,缺少 hasMore 终止证据") } + more, ok := rawHasMore.(bool) + if !ok { + return nil, devAppResponseError(operation, "malformed_has_more", "分页响应 hasMore 不是布尔值") + } + out["hasMore"] = more if hasCursor { - out["nextCursor"] = candidate["nextCursor"] + cursor, ok := rawCursor.(string) + if !ok { + return nil, devAppResponseError(operation, "malformed_cursor", "分页响应 nextCursor 不是字符串") + } + if strings.TrimSpace(cursor) != "" { + out["nextCursor"] = strings.TrimSpace(cursor) + } } - break + if more && devAppFirstString(out, "nextCursor") == "" { + return nil, devAppResponseError(operation, "missing_cursor", "分页响应 hasMore=true 但缺少可推进的 nextCursor") + } + return out, nil } - return out + return nil, devAppResponseError(operation, "missing_pagination", "分页响应缺少 hasMore 终止证据") } func devAppPaginationCandidates(data map[string]any) []map[string]any { - if data == nil { - return nil - } - candidates := []map[string]any{data} - for _, key := range []string{"content", "result", "data"} { - if nested, ok := data[key].(map[string]any); ok { - candidates = append(candidates, nested) - for _, innerKey := range []string{"result", "data"} { - if inner, ok := nested[innerKey].(map[string]any); ok { - candidates = append(candidates, inner) - } - } - } - } - return candidates + // Pagination evidence can occur at any of the reviewed envelope layers. + // Reuse the same bounded breadth-first walk as response validation so a + // valid content.result (or deeper content/result/data combination) cannot + // be mistaken for a response without a pagination terminus. + return devAppContainers(data) } // versionListProject reshapes list_dev_app_versions into a clean version list // ({versionId, version, status, desc, gmtCreate}) — output-projection fidelity // for clean output. The list container and per-item field names are probed defensively -// across candidate keys, so an unknown/empty shape yields an empty list rather -// than a crash or fabricated data. -func versionListProject(data map[string]any) []map[string]any { - raw := versionListFindList(data) +// across reviewed candidate keys, while missing, mistyped, or malformed +// collections fail closed. +func versionListProject(data map[string]any) ([]map[string]any, error) { + raw, _, err := requireDevAppCollection(data, "devapp/list_dev_app_versions", + []string{"versionId", "version_id", "id"}, "list", "items", "versions", "versionList") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + m := item.(map[string]any) row := map[string]any{} if v, ok := versionListFirst(m, "versionId", "version_id", "id"); ok { row["versionId"] = v @@ -1469,36 +2339,9 @@ func versionListProject(data map[string]any) []map[string]any { if v, ok := versionListFirst(m, "gmtCreate", "gmt_create", "createTime", "create_time"); ok { row["gmtCreate"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// versionListFindList locates the version list payload, tolerating a bare -// top-level array or nesting one level under a common envelope key. -func versionListFindList(data map[string]any) []any { - if data == nil { - return []any{} - } - for _, k := range []string{"list", "items", "versions", "versionList", "result", "data"} { - v, ok := data[k] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "versions", "versionList", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} + return out, nil } // versionListFirst returns the first present candidate key's value. @@ -1553,11 +2396,15 @@ var VersionGet = shortcut.Shortcut{ {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { + appID := rt.Str("unified-app-id") + versionID := rt.Str("version-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "versionId": rt.Str("version-id"), + "unifiedAppId": appID, + "versionId": versionID, } - return rt.CallMCP("get_dev_app_version_detail", params) + return outputDevAppObject(rt, "get_dev_app_version_detail", params, map[string]string{ + "unifiedAppId": appID, "versionId": versionID, + }) }, } @@ -1605,12 +2452,16 @@ var VersionCheckApproval = shortcut.Shortcut{ {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { + appID := rt.Str("unified-app-id") + versionID := rt.Str("version-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "versionId": rt.Str("version-id"), + "unifiedAppId": appID, + "versionId": versionID, "precheckOnly": true, } - return rt.CallMCP("publish_dev_app_version", params) + return outputDevAppObject(rt, "publish_dev_app_version", params, map[string]string{ + "unifiedAppId": appID, "versionId": versionID, + }) }, } @@ -1688,50 +2539,87 @@ var VersionStatus = shortcut.Shortcut{ {Name: "version-id", Type: shortcut.FlagString, Desc: "版本 ID", Required: true}, }, Execute: func(rt *shortcut.RuntimeContext) error { + appID := rt.Str("unified-app-id") + versionID := rt.Str("version-id") params := map[string]any{ - "unifiedAppId": rt.Str("unified-app-id"), - "versionId": rt.Str("version-id"), + "unifiedAppId": appID, + "versionId": versionID, } - return rt.CallMCP("get_dev_app_version_status", params) + return outputDevAppObject(rt, "get_dev_app_version_status", params, map[string]string{ + "unifiedAppId": appID, "versionId": versionID, + }) }, } +var devAppUnavailableReasons = map[string]string{ + "+permission-add": "权限申请缺少逐项终态回执与按 scopeValue 精确读回验证", + "+permission-remove": "权限移除缺少逐项终态回执与按 scopeValue 精确读回验证", + "+security-config": "整组覆盖写入缺少读取当前值、精确写回和恢复原配置的安全闭环", + "+event-unsubscribe": "事件退订缺少可恢复的稳定订阅 fixture 与逐项读回", + "+version-publish": "版本发布可能进入审批或线上生效,缺少安全测试租户与回滚合同", +} + +func unavailableDevAppShortcut(item shortcut.Shortcut, reason string) shortcut.Shortcut { + item.Hidden = true + item.Availability = shortcut.AvailabilityUnavailable + item.OutputRollout = output.RolloutDualValidate + item.Contract = corecmd.ContractDecl{} + constraintFlags := make([]string, 0, len(item.Flags)) + for _, flag := range item.Flags { + constraintFlags = append(constraintFlags, flag.Name) + } + item.Constraints = append(item.Constraints, shortcut.Constraint{ + Kind: shortcut.ConstraintCustom, + Flags: constraintFlags, + Description: "该命令保持 unavailable,直到文档化的安全验证前置条件满足", + }) + unavailable := func(*shortcut.RuntimeContext) error { + return apperrors.NewValidation("该 DevApp Shortcut 当前 unavailable:" + reason) + } + item.Validate = unavailable + item.Execute = unavailable + return item +} + func init() { - shortcut.Register( + items := []shortcut.Shortcut{ frameworkUnified(ListApp), frameworkUnified(GetApp), - frameworkDualValidate(CreateApp), - frameworkDualValidate(UpdateApp), - frameworkDualValidate(DeleteApp), - frameworkDualValidate(EnableApp), - frameworkDualValidate(DisableApp), - // Credentials contain secrets and still lack a reviewed public result - // projection. Keep legacy bytes while shadow-validating until Safety, - // Schema identity, and sensitive-path redaction are declared together. - frameworkDualValidate(GetCredentials), + frameworkUnified(CreateApp), + frameworkUnified(UpdateApp), + frameworkUnified(DeleteApp), + frameworkUnified(EnableApp), + frameworkUnified(DisableApp), + frameworkUnified(GetCredentials), frameworkUnified(WebappGet), - frameworkDualValidate(WebappConfig), + frameworkUnified(WebappConfig), frameworkUnified(PermissionList), frameworkDualValidate(PermissionAdd), frameworkDualValidate(PermissionRemove), - frameworkDualValidate(MemberList), - frameworkDualValidate(MemberAdd), - frameworkDualValidate(MemberRemove), + frameworkUnified(MemberList), + frameworkUnified(MemberAdd), + frameworkUnified(MemberRemove), frameworkDualValidate(SecurityConfig), frameworkUnified(RobotGet), - frameworkDualValidate(RobotConfig), - frameworkDualValidate(RobotEnable), - frameworkDualValidate(RobotDisable), + frameworkUnified(RobotConfig), + frameworkUnified(RobotEnable), + frameworkUnified(RobotDisable), frameworkUnified(EventList), - frameworkDualValidate(EventSubscribe), + frameworkUnified(EventSubscribe), frameworkDualValidate(EventUnsubscribe), - frameworkDualValidate(VersionCreate), + frameworkUnified(VersionCreate), frameworkUnified(VersionList), frameworkUnified(VersionGet), frameworkUnified(VersionCheckApproval), frameworkDualValidate(VersionPublish), frameworkUnified(VersionStatus), - ) + } + for index := range items { + if reason, unavailable := devAppUnavailableReasons[items[index].Command]; unavailable { + items[index] = unavailableDevAppShortcut(items[index], reason) + } + } + shortcut.Register(items...) } func frameworkUnified(item shortcut.Shortcut) shortcut.Shortcut { diff --git a/internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go b/internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..62de309e --- /dev/null +++ b/internal/shortcut/devapp/devapp_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,742 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package devapp + +import ( + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/spf13/cobra" +) + +func devAppSuccessResult(values map[string]any) map[string]any { + return map[string]any{"success": true, "result": values} +} + +func devAppSuccessList(key string, items []any, more bool, cursor string) map[string]any { + result := map[string]any{"success": true, key: items, "hasMore": more} + if cursor != "" { + result["nextCursor"] = cursor + } + return result +} + +func TestCrossPlatformCoverageDevAppChangedStrictHelpers(t *testing.T) { + if devAppContainers(nil) != nil { + t.Fatal("nil response unexpectedly produced containers") + } + for _, tc := range []struct { + value any + want bool + }{ + {nil, false}, {false, false}, {true, true}, + {float64(0), false}, {float64(200), false}, {float64(500), true}, + {"", false}, {"ok", false}, {"SUCCESS", false}, {"denied", true}, + {map[string]any{}, false}, {map[string]any{"code": 1}, true}, + {[]any{}, false}, {[]any{"error"}, true}, {struct{}{}, true}, + } { + if got := devAppErrorValue(tc.value); got != tc.want { + t.Fatalf("devAppErrorValue(%#v)=%v, want %v", tc.value, got, tc.want) + } + } + + if _, err := requireDevAppSuccess(map[string]any{ + "success": true, "result": map[string]any{"versionStatus": "FAILED"}, + }, "devapp/test"); err != nil { + t.Fatalf("successful query with failed business state was rejected: %v", err) + } + if !devAppOnlyEnvelopeFields(map[string]any{"success": true, "result": map[string]any{}}) { + t.Fatal("known envelope was not recognized") + } + if devAppOnlyEnvelopeFields(map[string]any{"success": true, "business": true}) { + t.Fatal("business field was treated as envelope-only") + } + if _, err := requireDevAppObject(map[string]any{"success": true, "result": map[string]any{}}, "devapp/test"); err == nil { + t.Fatal("empty business object was accepted") + } + + object := map[string]any{"id": "actual", "name": "actual"} + if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"ignored": ""}); err != nil { + t.Fatal(err) + } + if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"missing": "wanted"}); err == nil { + t.Fatal("missing identity was accepted") + } + if err := requireDevAppIdentity(object, "devapp/test", map[string]string{"id": "wanted"}); err == nil { + t.Fatal("mismatched identity was accepted") + } + if err := requireDevAppFields(object, "devapp/test", map[string]any{"missing": "wanted"}); err == nil { + t.Fatal("missing readback field was accepted") + } + if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "wanted"}); err == nil { + t.Fatal("mismatched readback field was accepted") + } + if err := requireDevAppFields(object, "devapp/test", map[string]any{"name": "actual"}); err != nil { + t.Fatal(err) + } + if err := requireDevAppStringField(object, "devapp/test", "wanted", "missing"); err == nil { + t.Fatal("missing string field was accepted") + } + + deep := map[string]any{"success": true, "content": map[string]any{ + "result": map[string]any{"hasMore": true, "nextCursor": " next "}, + }} + projection, err := devAppListProjection(deep, "items", nil, "devapp/test") + if err != nil || projection["nextCursor"] != "next" { + t.Fatalf("deep pagination projection=%#v err=%v", projection, err) + } + if _, err := devAppListProjection(map[string]any{"success": false}, "items", nil, "devapp/test"); err == nil { + t.Fatal("failed list envelope was accepted") + } +} + +func TestCrossPlatformCoverageDevAppChangedReadAndListExecutors(t *testing.T) { + t.Run("list all filters and projection", func(t *testing.T) { + item := map[string]any{ + "unified_app_id": "app", "appName": "name", "clientId": "client", + "agent_id": "agent", "app_status": "normal", "modified_time": "now", + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app": {devAppSuccessList("apps", []any{item}, true, "next")}, + }} + err := runDevAppCoverage(t, ListApp, caller, + "--name", "name", "--app-key", "client", "--app-group-id", "7", + "--creator", "owner", "--robot-name", "robot", "--develop-type", "2", + "--filter-cool-app", "1", "--sort-type", "gmt_modified", "--sort-order", "desc", + "--cursor", "cursor", "--page-size", "9") + if err != nil { + t.Fatal(err) + } + want := map[string]any{ + "name": "name", "appKey": "client", "appGroupId": 7, "creator": "owner", + "robotName": "robot", "developType": 2, "filterCoolApp": 1, + "sortType": "gmt_modified", "sortOrder": "desc", "cursor": "cursor", "pageSize": 9, + } + if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].params, want) { + t.Fatalf("list params=%#v, want %#v", caller.calls, want) + } + badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "app"}}}}, + }} + if err := runDevAppCoverage(t, ListApp, badPage); err == nil { + t.Fatal("list without terminal pagination evidence was accepted") + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + tool string + args []string + result map[string]any + }{ + {"get", GetApp, "get_dev_app", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "name": "app"}}, + {"webapp", WebappGet, "get_extension_webapp_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "homepageUrl": "https://example.invalid"}}, + {"robot", RobotGet, "get_extension_robot_config", []string{"--unified-app-id", "app"}, map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}}, + {"version", VersionGet, "get_dev_app_version_detail", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version"}}, + {"precheck", VersionCheckApproval, "publish_dev_app_version", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "approvalRequired": false}}, + {"status", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "status": "PUBLISHED"}}, + {"status failed", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "FAILED"}}, + {"status expired", VersionStatus, "get_dev_app_version_status", []string{"--unified-app-id", "app", "--version-id", "version"}, map[string]any{"unifiedAppId": "app", "versionId": "version", "versionStatus": "EXPIRED"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{tc.tool: {devAppSuccessResult(tc.result)}}} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + }) + } + + t.Run("read transport and identity failure", func(t *testing.T) { + if err := runDevAppCoverage(t, GetApp, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil { + t.Fatal("transport failure was accepted") + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "other"})}, + }} + if err := runDevAppCoverage(t, GetApp, caller, "--unified-app-id", "app"); err == nil { + t.Fatal("wrong object identity was accepted") + } + }) + + t.Run("permission list", func(t *testing.T) { + item := map[string]any{ + "permissionCode": "scope", "permissionName": "name", "interfaceName": "api", + "auth_status": "AUTHED", "scope_type": "APP", + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_permissions": {devAppSuccessList("permissions", []any{item}, false, "")}, + }} + err := runDevAppCoverage(t, PermissionList, caller, + "--unified-app-id", "app", "--keyword", "word", "--scope-value", "scope", + "--auth-status", "authed", "--scope-type", "app", "--api-status", "OPEN") + if err != nil { + t.Fatal(err) + } + if _, ok := permissionListFirst(item, "missing"); ok { + t.Fatal("missing permission field unexpectedly resolved") + } + }) + + t.Run("member list success and failures", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")}, + }} + if err := runDevAppCoverage(t, MemberList, caller, "--unified-app-id", "app", "--user-id", " user "); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, MemberList, &devAppCoverageCaller{}, "--unified-app-id", "app"); err == nil { + t.Fatal("member transport failure was accepted") + } + bad := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_members": {{"success": true, "members": "bad"}}, + }} + if err := runDevAppCoverage(t, MemberList, bad, "--unified-app-id", "app"); err == nil { + t.Fatal("malformed member list was accepted") + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + tool string + args []string + key string + item map[string]any + }{ + {"events", EventList, "list_dev_app_events", []string{"--unified-app-id", "app", "--keyword", "event"}, "events", map[string]any{"event_code": "event", "event_name": "name", "subscribe_status": "ON", "modified_time": "now"}}, + {"versions", VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", map[string]any{"version_id": "version", "version_name": "1", "versionStatus": "READY", "remark": "desc", "create_time": "now"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.tool: {devAppSuccessList(tc.key, []any{tc.item}, false, "")}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + }) + } + if _, ok := eventListFirst(map[string]any{}, "missing"); ok { + t.Fatal("missing event field unexpectedly resolved") + } + if _, ok := versionListFirst(map[string]any{}, "missing"); ok { + t.Fatal("missing version field unexpectedly resolved") + } +} + +func TestCrossPlatformCoverageDevAppChangedWriteExecutors(t *testing.T) { + t.Run("create all fields and failure branches", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{ + "unifiedAppId": "app", "name": "created", "desc": "desc", "iconMediaId": "icon", + })}, + }} + if err := runDevAppCoverage(t, CreateApp, caller, + "--name", "created", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{}, "--name", "created", "--yes"); err == nil { + t.Fatal("create transport failure was accepted") + } + for name, responses := range map[string]map[string][]map[string]any{ + "receipt object missing": {"create_dev_app": {{"success": true, "result": map[string]any{}}}}, + "resource id missing": {"create_dev_app": {devAppSuccessResult(map[string]any{"name": "created"})}}, + "readback failure": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + }, + "name mismatch": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})}, + }, + "description mismatch": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "desc": "other"})}, + }, + "icon mismatch": { + "create_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "created", "iconMediaId": "other"})}, + }, + } { + t.Run(name, func(t *testing.T) { + args := []string{"--name", "created", "--yes"} + if strings.Contains(name, "description") { + args = append(args, "--desc", "desc") + } + if strings.Contains(name, "icon") { + args = append(args, "--icon-media-id", "icon") + } + if err := runDevAppCoverage(t, CreateApp, &devAppCoverageCaller{responses: responses}, args...); err == nil { + t.Fatal("invalid create sequence was accepted") + } + }) + } + }) + + t.Run("update all fields and dry run", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "update_dev_app": {map[string]any{"success": true}}, + "get_dev_app": {devAppSuccessResult(map[string]any{ + "unifiedAppId": "app", "name": "new", "desc": "desc", "iconMediaId": "icon", + })}, + }} + if err := runDevAppCoverage(t, UpdateApp, caller, + "--unified-app-id", "app", "--name", "new", "--desc", "desc", "--icon-media-id", "icon", "--yes"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, UpdateApp, &devAppCoverageCaller{}, + "--unified-app-id", "app", "--name", "new", "--dry-run"); err != nil { + t.Fatal(err) + } + bad := &devAppCoverageCaller{responses: map[string][]map[string]any{"update_dev_app": {map[string]any{"success": true}}}} + if err := runDevAppCoverage(t, UpdateApp, bad, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil { + t.Fatal("update readback failure was accepted") + } + }) + + t.Run("delete guards and bounded readback", func(t *testing.T) { + if err := runDevAppCoverage(t, DeleteApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", ""); err == nil { + t.Fatal("delete verification without selector was accepted") + } + missingSelector := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app"})}, + }} + if err := runDevAppCoverage(t, DeleteApp, missingSelector, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete without appKey was accepted") + } + writeFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + }} + if err := runDevAppCoverage(t, DeleteApp, writeFailure, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete transport failure was accepted") + } + stillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": {devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "app"}}, false, "")}, + }} + if err := runDevAppCoverage(t, DeleteApp, stillPresent, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete with present readback was accepted") + } + stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": { + devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"), + devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "same"), + }, + }} + if err := runDevAppCoverage(t, DeleteApp, stalled, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete stalled pagination was accepted") + } + malformed := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": {{"success": true, "list": "bad", "hasMore": false}}, + }} + if err := runDevAppCoverage(t, DeleteApp, malformed, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete malformed readback was accepted") + } + badPage := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appKey": "key"})}, + "delete_dev_app": {map[string]any{"success": true}}, + "list_dev_app": {{"success": true, "list": []any{map[string]any{"unifiedAppId": "other"}}}}, + }} + if err := runDevAppCoverage(t, DeleteApp, badPage, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("delete pagination without termination was accepted") + } + pages := make([]map[string]any, 0, devAppMaxReadbackPages) + for page := 0; page < devAppMaxReadbackPages; page++ { + pages = append(pages, devAppSuccessList("list", []any{map[string]any{"unifiedAppId": "other"}}, true, "cursor-"+string(rune('a'+page)))) + } + pageLimitCaller := &devAppCoverageCaller{responses: map[string][]map[string]any{"list_dev_app": pages}} + helpers.InitDeps(pageLimitCaller) + if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil { + t.Fatal("delete verification page limit was accepted") + } + helpers.InitDeps(&devAppCoverageCaller{}) + if err := verifyDeletedDevApp(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+delete"}, DeleteApp), "app", "key"); err == nil { + t.Fatal("delete verification transport failure was accepted") + } + }) + + t.Run("credentials require client id", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appSecret": "secret"})}, + }} + if err := runDevAppCoverage(t, GetCredentials, caller, "--unified-app-id", "app"); err == nil { + t.Fatal("credential without client id was accepted") + } + }) + + t.Run("webapp all fields and failures", func(t *testing.T) { + args := []string{"--unified-app-id", "app", "--h5-page-type", "H5", "--homepage-url", "https://example.invalid/h5", "--pc-homepage-url", "https://example.invalid/pc", "--omp-url", "https://example.invalid/omp", "--yes"} + resource := map[string]any{"unifiedAppId": "app", "h5PageType": "H5", "homepageUrl": "https://example.invalid/h5", "pcHomepageUrl": "https://example.invalid/pc", "ompUrl": "https://example.invalid/omp"} + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {map[string]any{"success": true}}, + "get_extension_webapp_config": {devAppSuccessResult(resource)}, + }} + if err := runDevAppCoverage(t, WebappConfig, caller, args...); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, WebappConfig, &devAppCoverageCaller{}, args...); err == nil { + t.Fatal("webapp write failure was accepted") + } + badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {map[string]any{"success": true}}, + }} + if err := runDevAppCoverage(t, WebappConfig, badRead, args...); err == nil { + t.Fatal("webapp readback failure was accepted") + } + mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {map[string]any{"success": true}}, + "get_extension_webapp_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "h5PageType": "other"})}, + }} + if err := runDevAppCoverage(t, WebappConfig, mismatch, "--unified-app-id", "app", "--h5-page-type", "H5", "--yes"); err == nil { + t.Fatal("webapp mismatch was accepted") + } + }) +} + +func TestCrossPlatformCoverageDevAppChangedRobotEventAndVersionBranches(t *testing.T) { + t.Run("robot validate and all fields", func(t *testing.T) { + for _, args := range [][]string{ + {"--unified-app-id", "app", "--skills", "skill", "--yes"}, + {"--unified-app-id", "app", "--add-scope=false", "--yes"}, + } { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []any{"skill"}, "addScope": false})}, + }} + if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil { + t.Fatal(err) + } + } + args := []string{"--unified-app-id", "app", "--name", "bot", "--brief", "brief", "--desc", "desc", "--icon-media-id", "icon", "--outgoing-url", "https://example.invalid/out", "--event-callback-url", "https://example.invalid/event", "--mode", "STREAM", "--skills", "skill", "--add-scope=false", "--disable-ssl-verify=false", "--yes"} + resource := map[string]any{"unifiedAppId": "app", "name": "bot", "brief": "brief", "desc": "desc", "iconMediaId": "icon", "outgoingUrl": "https://example.invalid/out", "eventCallbackUrl": "https://example.invalid/event", "mode": "STREAM", "skills": []string{"skill"}, "addScope": false, "disableSSLVerify": false} + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(resource)}, + }} + if err := runDevAppCoverage(t, RobotConfig, caller, args...); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, "--unified-app-id", "app", "--name", "bot", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, RobotConfig, &devAppCoverageCaller{}, args...); err == nil { + t.Fatal("robot write failure was accepted") + } + badRead := &devAppCoverageCaller{responses: map[string][]map[string]any{"set_extension_robot_config": {map[string]any{"success": true}}}} + if err := runDevAppCoverage(t, RobotConfig, badRead, args...); err == nil { + t.Fatal("robot readback failure was accepted") + } + mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "name": "other"})}, + }} + if err := runDevAppCoverage(t, RobotConfig, mismatch, "--unified-app-id", "app", "--name", "bot", "--yes"); err == nil { + t.Fatal("robot mismatch was accepted") + } + boolMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "addScope": true})}, + }} + if err := runDevAppCoverage(t, RobotConfig, boolMismatch, "--unified-app-id", "app", "--add-scope=false", "--yes"); err == nil { + t.Fatal("robot boolean mismatch was accepted") + } + skillsMismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_robot_config": {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "skills": []string{"other"}})}, + }} + if err := runDevAppCoverage(t, RobotConfig, skillsMismatch, "--unified-app-id", "app", "--skills", "skill", "--yes"); err == nil { + t.Fatal("robot skills mismatch was accepted") + } + }) + + t.Run("status previews and readback failures", func(t *testing.T) { + if err := runDevAppCoverage(t, EnableApp, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil { + t.Fatal(err) + } + readFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "enable_dev_app": {map[string]any{"success": true}}, + }} + if err := runDevAppCoverage(t, EnableApp, readFailure, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("app status readback failure was accepted") + } + mismatch := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "enable_dev_app": {map[string]any{"success": true}}, + "get_dev_app": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "appStatus": "disabled"})}, + }} + if err := runDevAppCoverage(t, EnableApp, mismatch, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("app status mismatch was accepted") + } + if err := runDevAppCoverage(t, RobotEnable, &devAppCoverageCaller{}, "--unified-app-id", "app", "--dry-run"); err != nil { + t.Fatal(err) + } + robotReadFailure := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "enable_dev_app_robot": {map[string]any{"success": true}}, + }} + if err := runDevAppCoverage(t, RobotEnable, robotReadFailure, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("robot status readback failure was accepted") + } + }) + + t.Run("member mutations validate receipts and exact readback", func(t *testing.T) { + member := map[string]any{"userId": "user", "memberType": "DEVELOPER"} + add := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "add_dev_app_members": {{"success": true}}, + "list_dev_app_members": {devAppSuccessList("members", []any{member}, false, "")}, + }} + if err := runDevAppCoverage(t, MemberAdd, add, + "--unified-app-id", "app", "--user-ids", " user ", "--member-type", " DEVELOPER ", "--yes"); err != nil { + t.Fatal(err) + } + if len(add.calls) != 2 || add.calls[0].tool != "add_dev_app_members" || + !reflect.DeepEqual(add.calls[0].params["userIds"], []string{"user"}) || + add.calls[0].params["memberType"] != "DEVELOPER" || add.calls[1].tool != "list_dev_app_members" { + t.Fatalf("member add calls=%#v", add.calls) + } + + remove := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "remove_dev_app_members": {{"success": true}}, + "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "owner", "memberType": "OWNER"}}, false, "")}, + }} + if err := runDevAppCoverage(t, MemberRemove, remove, + "--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"); err != nil { + t.Fatal(err) + } + if len(remove.calls) != 2 || remove.calls[0].tool != "remove_dev_app_members" || remove.calls[1].tool != "list_dev_app_members" { + t.Fatalf("member remove calls=%#v", remove.calls) + } + + for _, declaration := range []shortcut.Shortcut{MemberAdd, MemberRemove} { + unconfirmed := &devAppCoverageCaller{} + if err := runDevAppCoverage(t, declaration, unconfirmed, + "--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER"); err == nil { + t.Fatalf("%s succeeded without confirmation", declaration.Command) + } + if len(unconfirmed.calls) != 0 { + t.Fatalf("%s made remote calls before confirmation: %#v", declaration.Command, unconfirmed.calls) + } + dryRun := &devAppCoverageCaller{} + if err := runDevAppCoverage(t, declaration, dryRun, + "--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--dry-run"); err != nil { + t.Fatal(err) + } + if len(dryRun.calls) != 0 { + t.Fatalf("%s dry-run made remote calls: %#v", declaration.Command, dryRun.calls) + } + } + }) + + t.Run("member mutations reject invalid input and false success", func(t *testing.T) { + for _, values := range [][]string{nil, {""}, {"user", "user"}} { + if _, err := validatedDevAppValues(values, "--user-ids"); err == nil { + t.Fatalf("invalid member IDs accepted: %#v", values) + } + } + if _, err := validatedDevAppMemberType(" "); err == nil { + t.Fatal("blank member type was accepted") + } + if got, err := validatedDevAppMemberType(" DEVELOPER "); err != nil || got != "DEVELOPER" { + t.Fatalf("normalized member type=%q err=%v", got, err) + } + for _, args := range [][]string{ + {"--unified-app-id", "app", "--user-ids", "user,user", "--member-type", "DEVELOPER", "--yes"}, + {"--unified-app-id", "app", "--user-ids", "user", "--member-type", " ", "--yes"}, + } { + caller := &devAppCoverageCaller{} + if err := runDevAppCoverage(t, MemberAdd, caller, args...); err == nil { + t.Fatalf("invalid member arguments accepted: %#v", args) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid member arguments made calls: %#v", caller.calls) + } + } + + baseArgs := []string{"--unified-app-id", "app", "--user-ids", "user", "--member-type", "DEVELOPER", "--yes"} + badAddResponses := []map[string][]map[string]any{ + {}, + {"add_dev_app_members": {{"success": false}}}, + {"add_dev_app_members": {{"success": true}}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {{"success": true, "members": "bad"}}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "other", "memberType": "DEVELOPER"}}, false, "")}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user"}}, false, "")}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{map[string]any{"userId": "user", "memberType": "OWNER"}}, false, "")}}, + {"add_dev_app_members": {{"success": true}}, "list_dev_app_members": {devAppSuccessList("members", []any{ + map[string]any{"userId": "user", "memberType": "DEVELOPER"}, + map[string]any{"userId": "user", "memberType": "DEVELOPER"}, + }, false, "")}}, + } + for index, responses := range badAddResponses { + if err := runDevAppCoverage(t, MemberAdd, &devAppCoverageCaller{responses: responses}, baseArgs...); err == nil { + t.Fatalf("member add false success case %d was accepted", index) + } + } + + removeStillPresent := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "remove_dev_app_members": {{"success": true}}, + "list_dev_app_members": {devAppSuccessList("members", []any{ + map[string]any{"userId": "user", "memberType": "DEVELOPER"}, + }, false, "")}, + }} + if err := runDevAppCoverage(t, MemberRemove, removeStillPresent, baseArgs...); err == nil { + t.Fatal("member remove accepted target still present") + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + writeTool string + status any + }{ + {"enable missing status", RobotEnable, "enable_dev_app_robot", nil}, + {"disable wrong status", RobotDisable, "disable_dev_app_robot", "ONLINE"}, + } { + t.Run(tc.name, func(t *testing.T) { + resource := map[string]any{"unifiedAppId": "app"} + if tc.status != nil { + resource["robotStatus"] = tc.status + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.writeTool: {map[string]any{"success": true}}, + "get_extension_robot_config": {devAppSuccessResult(resource)}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err == nil { + t.Fatal("invalid robot status was accepted") + } + }) + } + + t.Run("event validation and readback failures", func(t *testing.T) { + for _, values := range [][]string{nil, {""}, {"event", "event"}} { + if _, err := validatedDevAppValues(values, "--event-codes"); err == nil { + t.Fatalf("invalid event values accepted: %#v", values) + } + } + if got, err := validatedDevAppValues([]string{" event "}, "--event-codes"); err != nil || !reflect.DeepEqual(got, []string{"event"}) { + t.Fatalf("normalized event values=%#v err=%v", got, err) + } + if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("event write failure was accepted") + } + if err := runDevAppCoverage(t, EventSubscribe, &devAppCoverageCaller{}, "--unified-app-id", "app", "--event-codes", "event", "--dry-run"); err != nil { + t.Fatal(err) + } + for name, listResponses := range map[string][]map[string]any{ + "transport": nil, + "malformed": {{"success": true, "list": "bad", "hasMore": false}}, + "projection": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "")}, + "stalled": {devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same")}, + } { + t.Run(name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {map[string]any{"success": true}}, + "list_dev_app_events": listResponses, + }} + if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("invalid event readback was accepted") + } + }) + } + stalled := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {map[string]any{"success": true}}, + "list_dev_app_events": { + devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"), + devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "same"), + }, + }} + if err := runDevAppCoverage(t, EventSubscribe, stalled, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("stalled event cursor was accepted") + } + pages := make([]map[string]any, 0, devAppMaxReadbackPages) + for page := 0; page < devAppMaxReadbackPages; page++ { + pages = append(pages, devAppSuccessList("list", []any{map[string]any{"eventCode": "other"}}, true, "cursor-"+string(rune('a'+page)))) + } + pageLimit := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {map[string]any{"success": true}}, + "list_dev_app_events": pages, + }} + if err := runDevAppCoverage(t, EventSubscribe, pageLimit, "--unified-app-id", "app", "--event-codes", "event", "--yes"); err == nil { + t.Fatal("event readback page limit was accepted") + } + }) + + t.Run("version create all fields and failures", func(t *testing.T) { + args := []string{"--unified-app-id", "app", "--version", "1.0", "--desc", "desc", "--yes"} + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}, + "get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "desc"})}, + }} + if err := runDevAppCoverage(t, VersionCreate, caller, args...); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, "--unified-app-id", "app", "--desc", "desc", "--dry-run"); err != nil { + t.Fatal(err) + } + if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{}, args...); err == nil { + t.Fatal("version write failure was accepted") + } + for name, responses := range map[string]map[string][]map[string]any{ + "receipt object": {"create_dev_app_version": {{"success": true, "result": map[string]any{}}}}, + "missing id": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"version": "1.0"})}}, + "readback": {"create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}}, + "version mismatch": { + "create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}, + "get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "other"})}, + }, + "description mismatch": { + "create_dev_app_version": {devAppSuccessResult(map[string]any{"versionId": "version"})}, + "get_dev_app_version_detail": {devAppSuccessResult(map[string]any{"unifiedAppId": "app", "versionId": "version", "version": "1.0", "desc": "other"})}, + }, + } { + t.Run(name, func(t *testing.T) { + if err := runDevAppCoverage(t, VersionCreate, &devAppCoverageCaller{responses: responses}, args...); err == nil { + t.Fatal("invalid version create sequence was accepted") + } + }) + } + }) + + t.Run("list pagination projection failures", func(t *testing.T) { + for _, tc := range []struct { + declaration shortcut.Shortcut + tool string + args []string + key string + idKey string + }{ + {PermissionList, "list_dev_app_permissions", []string{"--unified-app-id", "app"}, "permissions", "scopeValue"}, + {EventList, "list_dev_app_events", []string{"--unified-app-id", "app"}, "events", "eventCode"}, + {VersionList, "list_dev_app_versions", []string{"--unified-app-id", "app"}, "versions", "versionId"}, + } { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.tool: {{"success": true, tc.key: []any{map[string]any{tc.idKey: "id"}}}}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatalf("%s accepted missing pagination", tc.declaration.Command) + } + } + malformedEvent := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "list_dev_app_events": {{"success": true, "events": "bad", "hasMore": false}}, + }} + if err := runDevAppCoverage(t, EventList, malformedEvent, "--unified-app-id", "app"); err == nil { + t.Fatal("malformed event collection was accepted") + } + }) +} diff --git a/internal/shortcut/devapp/devapp_hardening_test.go b/internal/shortcut/devapp/devapp_hardening_test.go new file mode 100644 index 00000000..3a151f0e --- /dev/null +++ b/internal/shortcut/devapp/devapp_hardening_test.go @@ -0,0 +1,527 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package devapp + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +type devAppCoverageCall struct { + tool string + params map[string]any +} + +type devAppCoverageCaller struct { + responses map[string][]map[string]any + calls []devAppCoverageCall +} + +func (c *devAppCoverageCaller) CallTool(_ context.Context, _, tool string, params map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, devAppCoverageCall{tool: tool, params: params}) + queue := c.responses[tool] + if len(queue) == 0 { + return nil, errors.New("unexpected devapp coverage call: " + tool) + } + payload := queue[0] + c.responses[tool] = queue[1:] + encoded, err := json.Marshal(payload) + if err != nil { + return nil, err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(encoded)}}}, nil +} + +func (*devAppCoverageCaller) Format() string { return "json" } +func (*devAppCoverageCaller) DryRun() bool { return false } +func (*devAppCoverageCaller) Fields() string { return "" } +func (*devAppCoverageCaller) JQ() string { return "" } + +func runDevAppCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "devapp"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.SetArgs(append([]string{"devapp", declaration.Command}, args...)) + return root.Execute() +} + +func runDevAppUnifiedCoverage(t *testing.T, declaration shortcut.Shortcut, caller *devAppCoverageCaller, args ...string) ([]byte, error) { + t.Helper() + helpers.InitDeps(caller) + ctx, _ := output.WithResultStore(context.Background()) + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + root.SetContext(ctx) + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + service := &cobra.Command{Use: "devapp"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetErr(io.Discard) + root.SetIn(strings.NewReader("")) + root.PersistentPostRunE = func(executed *cobra.Command, _ []string) error { + _, _, err := output.EmitStoredResult(executed) + return err + } + root.SetArgs(append([]string{"devapp", declaration.Command}, args...)) + if err := root.Execute(); err != nil { + return nil, err + } + return stdout.Bytes(), nil +} + +func devAppRegistered(command string) (shortcut.Shortcut, bool) { + for _, item := range shortcut.All() { + if item.Service == "devapp" && item.Command == command { + return item, true + } + } + return shortcut.Shortcut{}, false +} + +func TestCrossPlatformCoverageDevAppStrictResponseValidation(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "empty", data: nil}, + {name: "missing success", data: map[string]any{"list": []any{}}}, + {name: "wrong success type", data: map[string]any{"success": "true", "list": []any{}}}, + {name: "explicit failure", data: map[string]any{"success": false, "list": []any{}}}, + {name: "conflicting error", data: map[string]any{"success": true, "errorCode": "DENIED", "list": []any{}}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := listAppProject(tc.data); err == nil { + t.Fatal("malformed response was accepted") + } + }) + } + + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "missing collection", data: map[string]any{"success": true, "hasMore": false}}, + {name: "wrong collection type", data: map[string]any{"success": true, "list": map[string]any{}, "hasMore": false}}, + {name: "bad element", data: map[string]any{"success": true, "list": []any{"bad"}, "hasMore": false}}, + {name: "missing stable id", data: map[string]any{"success": true, "list": []any{map[string]any{"name": "app"}}, "hasMore": false}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := listAppProject(tc.data); err == nil { + t.Fatal("malformed collection was accepted") + } + }) + } + + valid := []map[string]any{{"unifiedAppId": "app"}} + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "missing pagination", data: map[string]any{"success": true}}, + {name: "wrong has more type", data: map[string]any{"success": true, "hasMore": "false"}}, + {name: "cursor without has more", data: map[string]any{"success": true, "nextCursor": "next"}}, + {name: "more without cursor", data: map[string]any{"success": true, "hasMore": true}}, + {name: "wrong cursor type", data: map[string]any{"success": true, "hasMore": true, "nextCursor": 1}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := devAppListProjection(tc.data, "apps", valid, "devapp/test"); err == nil { + t.Fatal("malformed pagination was accepted") + } + }) + } +} + +func TestCrossPlatformCoverageDevAppConfirmationAndValidationPrecedeCalls(t *testing.T) { + writes := []struct { + declaration shortcut.Shortcut + args []string + }{ + {CreateApp, []string{"--name", "app"}}, + {UpdateApp, []string{"--unified-app-id", "app", "--name", "new"}}, + {DeleteApp, []string{"--unified-app-id", "app"}}, + {EnableApp, []string{"--unified-app-id", "app"}}, + {DisableApp, []string{"--unified-app-id", "app"}}, + {WebappConfig, []string{"--unified-app-id", "app", "--homepage-url", "https://example.invalid"}}, + {RobotConfig, []string{"--unified-app-id", "app", "--name", "bot"}}, + {RobotEnable, []string{"--unified-app-id", "app"}}, + {RobotDisable, []string{"--unified-app-id", "app"}}, + {EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one"}}, + {VersionCreate, []string{"--unified-app-id", "app", "--desc", "version"}}, + } + for _, tc := range writes { + t.Run(tc.declaration.Command, func(t *testing.T) { + caller := &devAppCoverageCaller{} + err := runDevAppCoverage(t, tc.declaration, caller, tc.args...) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason != "confirmation_required" { + t.Fatalf("unconfirmed error = %#v, want confirmation_required", err) + } + if len(caller.calls) != 0 { + t.Fatalf("unconfirmed command called MCP: %#v", caller.calls) + } + }) + } + + for _, tc := range []struct { + declaration shortcut.Shortcut + args []string + }{ + {UpdateApp, []string{"--unified-app-id", "app", "--yes"}}, + {WebappConfig, []string{"--unified-app-id", "app", "--yes"}}, + {RobotConfig, []string{"--unified-app-id", "app", "--yes"}}, + {EventSubscribe, []string{"--unified-app-id", "app", "--event-codes", "event.one,event.one", "--yes"}}, + } { + caller := &devAppCoverageCaller{} + err := runDevAppCoverage(t, tc.declaration, caller, tc.args...) + var typed *apperrors.Error + if !errors.As(err, &typed) || typed.Reason == "confirmation_required" { + t.Fatalf("invalid input error = %#v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid input called MCP: %#v", caller.calls) + } + } +} + +func TestCrossPlatformCoverageDevAppResidualAdapters(t *testing.T) { + t.Run("credentials require stable identity client and secret", func(t *testing.T) { + valid := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appKey": "client", "appSecret": "secret", + }}}, + }} + if err := runDevAppCoverage(t, GetCredentials, valid, "--unified-app-id", "app"); err != nil { + t.Fatal(err) + } + missingSecret := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appKey": "client", + }}}, + }} + if err := runDevAppCoverage(t, GetCredentials, missingSecret, "--unified-app-id", "app"); err == nil { + t.Fatal("credential response without a secret was accepted") + } + }) + + t.Run("credentials unified data matches declared sensitive paths", func(t *testing.T) { + declaration, ok := devAppRegistered("+credentials-get") + if !ok || declaration.OutputRollout != output.RolloutUnifiedActive { + t.Fatalf("registered credentials shortcut is not unified: found=%t rollout=%q", ok, declaration.OutputRollout) + } + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app_credentials": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appKey": "client", "appSecret": "test-app-secret", + "clientSecret": "test-client-secret", "secret": "test-secret", + }}}, + }} + encoded, err := runDevAppUnifiedCoverage(t, declaration, caller, "--unified-app-id", "app") + if err != nil { + t.Fatal(err) + } + var envelope map[string]any + if err := json.Unmarshal(encoded, &envelope); err != nil { + t.Fatalf("unified output is not JSON: %v", err) + } + data, ok := envelope["data"].(map[string]any) + if !ok { + t.Fatalf("unified output data type = %T, want object", envelope["data"]) + } + if _, nested := data["result"]; nested { + t.Fatal("credential business object is nested under data.result; declared sensitive paths would miss it") + } + for _, path := range declaration.Contract.Result.SensitivePaths { + if value, present := data[path]; !present || strings.TrimSpace(fmt.Sprint(value)) == "" { + t.Fatalf("declared sensitive path %q does not resolve to a non-empty data field", path) + } + } + }) + + t.Run("member projector supports known and guaranteed zero", func(t *testing.T) { + members := []any{ + map[string]any{"userId": "user-1", "memberType": "OWNER"}, + map[string]any{"userId": "user-2", "memberType": "DEVELOPER"}, + } + if got := projectDevAppMembers(members, "user-1"); len(got) != 1 || got[0]["userId"] != "user-1" { + t.Fatalf("known member projection = %#v", got) + } + if got := projectDevAppMembers(members, "guaranteed-missing"); len(got) != 0 { + t.Fatalf("zero member projection = %#v", got) + } + for _, bad := range []map[string]any{ + {"success": true, "members": []any{"bad"}}, + {"success": true, "members": []any{map[string]any{"name": "missing-id"}}}, + } { + if _, _, err := requireDevAppCollection(bad, "devapp/list_dev_app_members", []string{"userId"}, "members"); err == nil { + t.Fatalf("malformed member response accepted: %#v", bad) + } + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string][]map[string]any + wantTools []string + }{ + { + name: "robot config", declaration: RobotConfig, + args: []string{"--unified-app-id", "app", "--name", "bot", "--mode", "STREAM", "--yes"}, + responses: map[string][]map[string]any{ + "set_extension_robot_config": {{"success": true}}, + "get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "bot", "mode": "STREAM"}}}, + }, + wantTools: []string{"set_extension_robot_config", "get_extension_robot_config"}, + }, + { + name: "robot enable", declaration: RobotEnable, + args: []string{"--unified-app-id", "app", "--yes"}, + responses: map[string][]map[string]any{ + "enable_dev_app_robot": {{"success": true}}, + "get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "ONLINE"}}}, + }, + wantTools: []string{"enable_dev_app_robot", "get_extension_robot_config"}, + }, + { + name: "robot disable", declaration: RobotDisable, + args: []string{"--unified-app-id", "app", "--yes"}, + responses: map[string][]map[string]any{ + "disable_dev_app_robot": {{"success": true}}, + "get_extension_robot_config": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "robotStatus": "UNCONFIGURED"}}}, + }, + wantTools: []string{"disable_dev_app_robot", "get_extension_robot_config"}, + }, + { + name: "event subscribe", declaration: EventSubscribe, + args: []string{"--unified-app-id", "app", "--event-codes", "event.one", "--yes"}, + responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {{"success": true}}, + "list_dev_app_events": {{"success": true, "list": []any{map[string]any{"eventCode": "event.one"}}, "hasMore": false}}, + }, + wantTools: []string{"subscribe_dev_app_events", "list_dev_app_events"}, + }, + { + name: "version create", declaration: VersionCreate, + args: []string{"--unified-app-id", "app", "--desc", "snapshot", "--yes"}, + responses: map[string][]map[string]any{ + "create_dev_app_version": {{"success": true, "result": map[string]any{"versionId": "version"}}}, + "get_dev_app_version_detail": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "versionId": "version", "desc": "snapshot"}}}, + }, + wantTools: []string{"create_dev_app_version", "get_dev_app_version_detail"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: tc.responses} + if err := runDevAppCoverage(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + gotTools := make([]string, 0, len(caller.calls)) + for _, call := range caller.calls { + gotTools = append(gotTools, call.tool) + } + if !reflect.DeepEqual(gotTools, tc.wantTools) { + t.Fatalf("tools = %#v, want %#v", gotTools, tc.wantTools) + } + }) + } + + t.Run("event false receipt is rejected by readback", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "subscribe_dev_app_events": {{"success": true}}, + "list_dev_app_events": {{"success": true, "list": []any{}, "hasMore": false}}, + }} + if err := runDevAppCoverage(t, EventSubscribe, caller, "--unified-app-id", "app", "--event-codes", "event.one", "--yes"); err == nil { + t.Fatal("event subscription without exact readback was accepted") + } + }) +} + +func TestCrossPlatformCoverageDevAppWritesUseExactReadback(t *testing.T) { + t.Run("create", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "create_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app"}}}, + "get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "created"}}}, + }} + if err := runDevAppCoverage(t, CreateApp, caller, "--name", "created", "--yes"); err != nil { + t.Fatal(err) + } + want := []devAppCoverageCall{ + {tool: "create_dev_app", params: map[string]any{"name": "created"}}, + {tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}}, + } + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) + + t.Run("update mismatch fails", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "update_dev_app": {{"success": true}}, + "get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "name": "old"}}}, + }} + if err := runDevAppCoverage(t, UpdateApp, caller, "--unified-app-id", "app", "--name", "new", "--yes"); err == nil { + t.Fatal("write/readback mismatch was accepted") + } + if len(caller.calls) != 2 || caller.calls[0].tool != "update_dev_app" || caller.calls[1].tool != "get_dev_app" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + writeTool string + status string + }{ + {name: "enable", declaration: EnableApp, writeTool: "enable_dev_app", status: "normal"}, + {name: "disable", declaration: DisableApp, writeTool: "disable_dev_app", status: "disabled"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + tc.writeTool: {{"success": true}}, + "get_dev_app": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "appStatus": tc.status, + }}}, + }} + if err := runDevAppCoverage(t, tc.declaration, caller, "--unified-app-id", "app", "--yes"); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || caller.calls[0].tool != tc.writeTool || caller.calls[1].tool != "get_dev_app" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + } + + t.Run("webapp config", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "set_extension_webapp_config": {{"success": true}}, + "get_extension_webapp_config": {{"success": true, "result": map[string]any{ + "unifiedAppId": "app", "homepageUrl": "https://example.invalid", + }}}, + }} + if err := runDevAppCoverage(t, WebappConfig, caller, "--unified-app-id", "app", "--homepage-url", "https://example.invalid", "--yes"); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || caller.calls[0].tool != "set_extension_webapp_config" || caller.calls[1].tool != "get_extension_webapp_config" { + t.Fatalf("calls = %#v", caller.calls) + } + }) + + t.Run("delete pre-read and absence", func(t *testing.T) { + caller := &devAppCoverageCaller{responses: map[string][]map[string]any{ + "get_dev_app": {{"success": true, "result": map[string]any{"unifiedAppId": "app", "appKey": "key"}}}, + "delete_dev_app": {{"success": true}}, + "list_dev_app": {{"success": true, "list": []any{}, "hasMore": false}}, + }} + if err := runDevAppCoverage(t, DeleteApp, caller, "--unified-app-id", "app", "--yes"); err != nil { + t.Fatal(err) + } + want := []devAppCoverageCall{ + {tool: "get_dev_app", params: map[string]any{"unifiedAppId": "app"}}, + {tool: "delete_dev_app", params: map[string]any{"unifiedAppId": "app"}}, + {tool: "list_dev_app", params: map[string]any{"appKey": "key", "pageSize": 20}}, + } + if !reflect.DeepEqual(caller.calls, want) { + t.Fatalf("calls = %#v, want %#v", caller.calls, want) + } + }) +} + +func TestCrossPlatformCoverageDevAppReviewedInventory(t *testing.T) { + public := 0 + unavailable := 0 + total := 0 + for _, item := range shortcut.All() { + if item.Service != "devapp" { + continue + } + total++ + if item.Availability == shortcut.AvailabilityUnavailable { + unavailable++ + if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog(item.Service, item.Command) { + t.Errorf("unavailable %s visibility/public = hidden=%v compatibility=%v public=%v", + item.Command, item.Hidden, item.CompatibilityVisible, shortcut.InPublicCatalog(item.Service, item.Command)) + } + if strings.TrimSpace(devAppUnavailableReasons[item.Command]) == "" { + t.Errorf("unavailable %s lacks concrete reason", item.Command) + } + if !item.Contract.Empty() { + t.Errorf("hidden unavailable %s must not publish an incomplete Contract", item.Command) + } + continue + } + if shortcut.InPublicCatalog(item.Service, item.Command) { + public++ + if item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("public %s visibility/availability = %v/%q", item.Command, item.Hidden, item.Availability) + } + if item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("public %s rollout = %q", item.Command, item.OutputRollout) + } + if item.Contract.Result == nil || item.Contract.Interface == nil || item.Contract.Description == "" { + t.Errorf("public %s has incomplete Contract/Result", item.Command) + } + if item.Safety.Effect == "" || item.Safety.Risk == "" || item.Safety.Confirmation == "" || item.Safety.Idempotency == "" { + t.Errorf("public %s has incomplete Safety", item.Command) + } + } + } + if total != 30 || public != 25 || unavailable != 5 { + t.Fatalf("devapp inventory total/public/unavailable = %d/%d/%d, want 30/25/5", total, public, unavailable) + } +} + +func TestCrossPlatformCoverageDevAppUnavailableCommandsMakeZeroCalls(t *testing.T) { + for command, reason := range devAppUnavailableReasons { + declaration, found := devAppRegistered(command) + if !found { + t.Fatalf("registered unavailable command missing: %s", command) + } + args := []string{} + for _, flag := range declaration.Flags { + if !flag.Required { + continue + } + args = append(args, "--"+flag.Name) + if flag.Type != shortcut.FlagBool { + args = append(args, "fixture") + } + } + caller := &devAppCoverageCaller{} + err := runDevAppCoverage(t, declaration, caller, args...) + if err == nil || !strings.Contains(err.Error(), reason) { + t.Errorf("%s unavailable error = %v", command, err) + } + if len(caller.calls) != 0 { + t.Errorf("%s unavailable command called MCP: %#v", command, caller.calls) + } + } +} diff --git a/internal/shortcut/devapp/devapp_pagination_projection_test.go b/internal/shortcut/devapp/devapp_pagination_projection_test.go index eeb6048b..3404e162 100644 --- a/internal/shortcut/devapp/devapp_pagination_projection_test.go +++ b/internal/shortcut/devapp/devapp_pagination_projection_test.go @@ -32,12 +32,15 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) { name string data map[string]any }{ - {name: "top level", data: map[string]any{"hasMore": true, "nextCursor": "next"}}, - {name: "nested result", data: map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "next"}}}, - {name: "exhausted", data: map[string]any{"hasMore": false}}, + {name: "top level", data: map[string]any{"success": true, "hasMore": true, "nextCursor": "next"}}, + {name: "nested result", data: map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "next"}}}, + {name: "exhausted", data: map[string]any{"success": true, "hasMore": false}}, } { t.Run(tc.name, func(t *testing.T) { - got := devAppListProjection(tc.data, "items", items) + got, err := devAppListProjection(tc.data, "items", items, "devapp/test") + if err != nil { + t.Fatal(err) + } if got["hasMore"] != devAppPaginationCandidates(tc.data)[len(devAppPaginationCandidates(tc.data))-1]["hasMore"] && tc.name == "nested result" { t.Fatalf("hasMore not preserved: %#v", got) } @@ -52,14 +55,23 @@ func TestDevAppListProjectionPreservesPaginationEvidence(t *testing.T) { if got := devAppPaginationCandidates(nil); got != nil { t.Fatalf("nil candidates=%#v", got) } - deep := map[string]any{"content": map[string]any{"result": map[string]any{"hasMore": true, "nextCursor": "deep"}}} - if got := devAppListProjection(deep, "items", nil); got["nextCursor"] != "deep" { + deep := map[string]any{"content": map[string]any{"success": true, "result": map[string]any{"hasMore": true, "nextCursor": "deep"}}} + if got, err := devAppListProjection(deep, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deep" { t.Fatalf("deep projection=%#v", got) } + deeper := map[string]any{"success": true, "content": map[string]any{"result": map[string]any{"data": map[string]any{"hasMore": true, "nextCursor": "deeper"}}}} + if got, err := devAppListProjection(deeper, "items", nil, "devapp/test"); err != nil || got["nextCursor"] != "deeper" { + t.Fatalf("deeper projection=%#v", got) + } + cyclic := map[string]any{"success": true} + cyclic["content"] = cyclic + if got := devAppPaginationCandidates(cyclic); len(got) != 13 { + t.Fatalf("cyclic candidate walk length=%d, want bounded length 13", len(got)) + } } func TestFrameworkPaginatedShortcutExecutionMovesCursorToMeta(t *testing.T) { - caller := &paginationCaller{text: `{"content":{"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`} + caller := &paginationCaller{text: `{"content":{"success":true,"result":{"hasMore":true,"nextCursor":"next","list":[]}}}`} helpers.InitDepsForTest(t, caller) helpers.GetFormatter().SetWriters(&bytes.Buffer{}, &bytes.Buffer{}) for _, tc := range []struct { diff --git a/internal/shortcut/devapp/devapp_rollout_test.go b/internal/shortcut/devapp/devapp_rollout_test.go index d89ac792..dd46a96e 100644 --- a/internal/shortcut/devapp/devapp_rollout_test.go +++ b/internal/shortcut/devapp/devapp_rollout_test.go @@ -12,10 +12,16 @@ import ( func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) { active := map[string]bool{ - "+list": true, "+get": true, "+webapp-get": true, - "+permission-list": true, "+event-list": true, "+version-list": true, + "+list": true, "+get": true, "+create": true, "+update": true, + "+delete": true, "+enable": true, "+disable": true, "+webapp-get": true, + "+webapp-config": true, + "+permission-list": true, "+version-list": true, "+robot-get": true, "+version-get": true, "+version-check-approval": true, "+version-status": true, + "+credentials-get": true, "+member-list": true, + "+member-add": true, "+member-remove": true, + "+robot-config": true, "+robot-enable": true, "+robot-disable": true, + "+event-list": true, "+event-subscribe": true, "+version-create": true, } seen := map[string]bool{} for _, item := range shortcut.All() { @@ -42,7 +48,7 @@ func TestDevAppShortcutsRollOutPerTerminalCommand(t *testing.T) { t.Errorf("active pilot shortcut %s is not registered", name) } } - for _, paginated := range []string{"+list", "+permission-list", "+event-list", "+version-list"} { + for _, paginated := range []string{"+list", "+permission-list", "+version-list"} { if !seen[paginated] { t.Errorf("paginated shortcut %s is not registered", paginated) } diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 081b94a9..dd44d89d 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -6,6 +6,11 @@ package shortcut // command discovery and skill generation. func generatedPublicShortcutCatalog() map[string]struct{} { return map[string]struct{}{ + "agoal\u0000+contract-fields": {}, + "agoal\u0000+obj-template-list": {}, + "agoal\u0000+report-statistics-list": {}, + "agoal\u0000+report-submit-detail": {}, + "agoal\u0000+user-rules": {}, "aisearch\u0000+search-person": {}, "aitable\u0000+advperm-disable": {}, "aitable\u0000+advperm-enable": {}, @@ -254,19 +259,25 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "contact\u0000+search-user": {}, "contact\u0000+team": {}, "devapp\u0000+create": {}, + "devapp\u0000+credentials-get": {}, "devapp\u0000+delete": {}, "devapp\u0000+disable": {}, "devapp\u0000+enable": {}, "devapp\u0000+event-list": {}, + "devapp\u0000+event-subscribe": {}, "devapp\u0000+get": {}, "devapp\u0000+list": {}, "devapp\u0000+member-add": {}, "devapp\u0000+member-list": {}, "devapp\u0000+member-remove": {}, "devapp\u0000+permission-list": {}, + "devapp\u0000+robot-config": {}, + "devapp\u0000+robot-disable": {}, + "devapp\u0000+robot-enable": {}, "devapp\u0000+robot-get": {}, "devapp\u0000+update": {}, "devapp\u0000+version-check-approval": {}, + "devapp\u0000+version-create": {}, "devapp\u0000+version-get": {}, "devapp\u0000+version-list": {}, "devapp\u0000+version-status": {}, diff --git a/internal/shortcut/runner.go b/internal/shortcut/runner.go index f265ce60..6e50f376 100644 --- a/internal/shortcut/runner.go +++ b/internal/shortcut/runner.go @@ -336,6 +336,23 @@ func (rt *RuntimeContext) Output(payload any) error { return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON) } +// OutputForTool stores a once-fetched MCP payload while retaining the tool name +// for product-specific outcome normalization. It is intended for composite +// shortcuts that must validate stable identity or collection shape before the +// shared renderer sees the response; callers must not use it to issue a second +// business request. +func (rt *RuntimeContext) OutputForTool(tool string, payload any) error { + if output.UsesUnifiedResult(rt.cmd) { + return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload)) + } + if output.CommandRollout(rt.cmd) == output.RolloutDualValidate { + if err := validateShadowResult(rt.resultForPayload(tool, payload)); err != nil { + return err + } + } + return output.WriteCommandPayload(rt.cmd, payload, output.FormatJSON) +} + func (rt *RuntimeContext) storePayload(tool string, payload any) error { return output.StoreResult(rt.cmd.Context(), rt.resultForPayload(tool, payload)) } diff --git a/internal/shortcut/runner_coverage_test.go b/internal/shortcut/runner_coverage_test.go index bb83d228..9d2ce47b 100644 --- a/internal/shortcut/runner_coverage_test.go +++ b/internal/shortcut/runner_coverage_test.go @@ -14,6 +14,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" "github.com/spf13/cobra" ) @@ -231,3 +232,53 @@ func TestFrameworkShortcutUnifiedOutputAndProjectionEdges(t *testing.T) { t.Fatalf("devapp result=%s", got.Outcome()) } } + +func TestCrossPlatformCoverageRuntimeOutputForToolRollouts(t *testing.T) { + t.Run("unified", func(t *testing.T) { + ctx, _ := output.WithResultStore(context.Background()) + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(ctx) + output.SetCommandRollout(cmd, output.RolloutUnifiedActive) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("dual validate", func(t *testing.T) { + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(context.Background()) + cmd.SetOut(&bytes.Buffer{}) + output.SetCommandRollout(cmd, output.RolloutDualValidate) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true, "result": map[string]any{"unifiedAppId": "app"}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("dual validate failure", func(t *testing.T) { + testseam.Swap(t, &validateShadowResult, func(output.CommandResult) error { return context.Canceled }) + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(context.Background()) + output.SetCommandRollout(cmd, output.RolloutDualValidate) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "devapp", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get_dev_app", map[string]any{"success": true}); !errors.Is(err, context.Canceled) { + t.Fatalf("dual validation error=%v", err) + } + }) + + t.Run("legacy", func(t *testing.T) { + cmd := &cobra.Command{Use: "+get"} + cmd.SetContext(context.Background()) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + output.SetCommandRollout(cmd, output.RolloutLegacyOnly) + rt := RuntimeContextForTest(cmd, Shortcut{Service: "sample", Command: "+get", Safety: contract.SafetySpec{Effect: "read"}}) + if err := rt.OutputForTool("get", map[string]any{"id": "item"}); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), `"id"`) { + t.Fatalf("legacy output=%q", stdout.String()) + } + }) +} diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index b7584c43..4713ad3f 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -73,6 +73,12 @@ var hrbrainSemanticCatalogJSON []byte //go:embed semantic_catalog_pat.json var patSemanticCatalogJSON []byte +//go:embed semantic_catalog_devapp.json +var devAppSemanticCatalogJSON []byte + +//go:embed semantic_catalog_agoal.json +var agoalSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -113,6 +119,8 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( devdocSemanticCatalogJSON, hrbrainSemanticCatalogJSON, patSemanticCatalogJSON, + devAppSemanticCatalogJSON, + agoalSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { diff --git a/internal/shortcut/semantic_catalog_agoal.json b/internal/shortcut/semantic_catalog_agoal.json new file mode 100644 index 00000000..9db46f1f --- /dev/null +++ b/internal/shortcut/semantic_catalog_agoal.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "service": "agoal", + "default_availability": "available", + "shortcuts": { + "+report-statistics-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。","risk":"read","public":true,"reviewed":true}, + "+obj-template-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。","risk":"read","public":true,"reviewed":true}, + "+strategy-list": {"disposition":"schema_leaf","semantic_delta":"exact+raw 在可访问 PERSONAL/DEPT 范围均仅为空,缺少已知非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+strategy-get": {"disposition":"schema_leaf","semantic_delta":"不存在 ID 的 raw 响应为无 success 的 null,且列表无非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+strategy-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+contract-list": {"disposition":"schema_leaf","semantic_delta":"exact+raw 在可访问 PERSONAL/DEPT 范围均仅为空,缺少已知非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+contract-fields": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。","risk":"read","public":true,"reviewed":true}, + "+contract-get": {"disposition":"schema_leaf","semantic_delta":"不存在 ID 的 raw 响应为无 success 的 null,且列表无非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+contract-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+scorecard-get": {"disposition":"schema_leaf","semantic_delta":"可访问部门与多个周期的 exact+raw 均返回无 success 的 null。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+scorecard-entity-get": {"disposition":"schema_leaf","semantic_delta":"不存在双 ID 的 raw 响应为无 success 的 null,缺少安全实体 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+scorecard-update": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;缺少计分卡旧值、全字段读回和恢复。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+user-rules": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。","risk":"read","public":true,"reviewed":true}, + "+user-objectives": {"disposition":"schema_leaf","semantic_delta":"当前用户全部可见规则/周期的 exact+raw 均只为空,缺少非空 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+report-submit-detail": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。","risk":"read","public":true,"reviewed":true}, + "+obj-template-upsert": {"disposition":"schema_leaf","semantic_delta":"仅通过 raw dry-run;无删除或版本恢复,无法验证 upsert 终态与零残留。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_devapp.json b/internal/shortcut/semantic_catalog_devapp.json new file mode 100644 index 00000000..886d6ab2 --- /dev/null +++ b/internal/shortcut/semantic_catalog_devapp.json @@ -0,0 +1,37 @@ +{ + "version": 1, + "service": "devapp", + "default_availability": "available", + "shortcuts": { + "+list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。","risk":"read","public":true,"reviewed":true}, + "+get": {"disposition":"semantic_adapter","semantic_delta":"拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+create": {"disposition":"semantic_adapter","semantic_delta":"创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。","risk":"write","public":true,"reviewed":true}, + "+update": {"disposition":"semantic_adapter","semantic_delta":"要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。","risk":"write","public":true,"reviewed":true}, + "+delete": {"disposition":"semantic_adapter","semantic_delta":"删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。","risk":"high-risk-write","public":true,"reviewed":true}, + "+enable": {"disposition":"semantic_adapter","semantic_delta":"启用后按同一 unifiedAppId 读回并要求 appStatus=normal。","risk":"write","public":true,"reviewed":true}, + "+disable": {"disposition":"semantic_adapter","semantic_delta":"停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。","risk":"high-risk-write","public":true,"reviewed":true}, + "+credentials-get": {"disposition":"semantic_adapter","semantic_delta":"严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。","risk":"read","public":true,"reviewed":true}, + "+webapp-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+webapp-config": {"disposition":"semantic_adapter","semantic_delta":"拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。","risk":"write","public":true,"reviewed":true}, + "+permission-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。","risk":"read","public":true,"reviewed":true}, + "+permission-add": {"disposition":"schema_leaf","semantic_delta":"下游缺少逐项终态回执与按 scopeValue 精确读回验证,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+permission-remove": {"disposition":"schema_leaf","semantic_delta":"下游缺少逐项终态回执与按 scopeValue 精确读回验证,执行入口保持 unavailable。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+member-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。","risk":"read","public":true,"reviewed":true}, + "+member-add": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。","risk":"write","public":true,"reviewed":true}, + "+member-remove": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。","risk":"high-risk-write","public":true,"reviewed":true}, + "+security-config": {"disposition":"schema_leaf","semantic_delta":"整组覆盖写入缺少读取旧值、精确写回和恢复原配置的安全闭环,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+robot-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+robot-config": {"disposition":"semantic_adapter","semantic_delta":"拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。","risk":"write","public":true,"reviewed":true}, + "+robot-enable": {"disposition":"semantic_adapter","semantic_delta":"要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。","risk":"write","public":true,"reviewed":true}, + "+robot-disable": {"disposition":"semantic_adapter","semantic_delta":"要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。","risk":"high-risk-write","public":true,"reviewed":true}, + "+event-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。","risk":"read","public":true,"reviewed":true}, + "+event-subscribe": {"disposition":"semantic_adapter","semantic_delta":"拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。","risk":"write","public":true,"reviewed":true}, + "+event-unsubscribe": {"disposition":"schema_leaf","semantic_delta":"缺少可恢复的稳定订阅 fixture 与逐项退订读回,执行入口保持 unavailable。","risk":"high-risk-write","availability":"unavailable","public":false,"reviewed":true}, + "+version-create": {"disposition":"semantic_adapter","semantic_delta":"创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。","risk":"write","public":true,"reviewed":true}, + "+version-list": {"disposition":"semantic_adapter","semantic_delta":"严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。","risk":"read","public":true,"reviewed":true}, + "+version-get": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。","risk":"read","public":true,"reviewed":true}, + "+version-check-approval": {"disposition":"semantic_adapter","semantic_delta":"只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。","risk":"read","public":true,"reviewed":true}, + "+version-publish": {"disposition":"schema_leaf","semantic_delta":"可能进入审批或线上生效,缺少安全测试租户与回滚合同,执行入口保持 unavailable。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+version-status": {"disposition":"semantic_adapter","semantic_delta":"严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。","risk":"read","public":true,"reviewed":true} + } +} diff --git a/scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh b/scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh new file mode 100755 index 00000000..d9bb9a97 --- /dev/null +++ b/scripts/dev/dev-devapp-agoal-shortcut-dual-live-gate.sh @@ -0,0 +1,798 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Opt-in, real-data release evidence gate for the Dev -> DevApp -> Agoal +# Shortcut delivery group. The current worktree binary supplies both exact +# `+shortcut` leaves and their owning atomic command trees. Raw responses and +# stable IDs stay in a repository-external temporary directory; stdout contains +# only PII-free PASS labels and aggregate facts. + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) +dws_bin=${DWS_SHORTCUT_GATE_BIN:-"$repo_root/dws"} +selected_product=${1:-all} + +case "$selected_product" in + all | dev | devapp | agoal) ;; + *) + echo "usage: $0 [all|dev|devapp|agoal]" >&2 + exit 2 + ;; +esac + +if [[ ${DWS_SHORTCUT_LIVE_GATE:-} != 1 ]]; then + echo "set DWS_SHORTCUT_LIVE_GATE=1 to authorize the real-data dual-layer gate" >&2 + exit 2 +fi +if [[ $selected_product == all || $selected_product == devapp ]]; then + if [[ ! -t 0 ]]; then + echo "the DevApp live gate requires an interactive terminal for explicit write and cleanup confirmation" >&2 + exit 2 + fi + read -r -p "This gate creates, changes, and deletes isolated DevApp fixtures. Type RUN to authorize the writes and cleanup: " live_confirmation + if [[ $live_confirmation != RUN ]]; then + echo "live gate authorization declined" >&2 + exit 2 + fi +fi +if [[ ! -x $dws_bin ]]; then + echo "current worktree binary is missing; run make build first" >&2 + exit 2 +fi +command -v jq >/dev/null 2>&1 || { + echo "jq is required" >&2 + exit 2 +} + +member_user_id="" +member_type=${DWS_DEVAPP_E2E_MEMBER_TYPE:-DEVELOPER} +if [[ $selected_product == all || $selected_product == devapp ]]; then + member_user_id_file=${DWS_DEVAPP_E2E_MEMBER_USER_ID_FILE:-} + if [[ -z $member_user_id_file || ! -f $member_user_id_file ]]; then + echo "set DWS_DEVAPP_E2E_MEMBER_USER_ID_FILE to a private file containing one safe test-account userId" >&2 + exit 2 + fi + IFS= read -r member_user_id <"$member_user_id_file" || true + [[ -n $member_user_id ]] || { + echo "the private DevApp member fixture is empty" >&2 + exit 2 + } + [[ -n $member_type ]] || { + echo "DWS_DEVAPP_E2E_MEMBER_TYPE must not be empty" >&2 + exit 2 + } +fi + +evidence_dir=$(mktemp -d "${TMPDIR:-/tmp}/dws-shortcut-dual-live.XXXXXX") +exact_app_id="" +exact_app_name="" +raw_app_id="" +raw_app_name="" + +fail() { + echo "FAIL $1 (secure response retained only until trap cleanup)" >&2 + exit 1 +} + +run_json() { + local label=$1 + local output=$2 + shift 2 + if ! "$@" --format json >"$output" 2>"$output.stderr"; then + fail "$label" + fi + jq -e . "$output" >/dev/null 2>&1 || fail "$label: non-JSON response" +} + +assert_jq() { + local label=$1 + local expression=$2 + shift 2 + jq -e "$expression" "$@" >/dev/null 2>&1 || fail "$label" +} + +pass() { + printf 'PASS %s\n' "$1" +} + +assert_public_surface() { + local product=$1 + local expected=$2 + local actual + actual=$(jq -r --arg product "$product" '.results[] | select(.service == $product) | .command' "$repo_root/docs/shortcut-public-catalog.json" | LC_ALL=C sort) + expected=$(printf '%s\n' "$expected" | sed '/^$/d' | LC_ALL=C sort) + [[ $actual == "$expected" ]] || fail "$product public catalog differs from the exhaustive live matrix" +} + +cleanup() { + set +e + if [[ -n $exact_app_id ]]; then + "$dws_bin" devapp +delete --unified-app-id "$exact_app_id" --yes --format json >/dev/null 2>&1 + fi + if [[ -n $raw_app_id ]]; then + "$dws_bin" dev app delete --unified-app-id "$raw_app_id" --confirm-name "$raw_app_name" --yes --format json >/dev/null 2>&1 + fi + rm -rf "$evidence_dir" +} +trap cleanup EXIT INT TERM + +run_dev_gate() { + # Dev owns routing/documentation only and registers no Shortcut leaves. + assert_public_surface dev "" + pass "DEV-INVENTORY public=0 fully-unlocked=0 hardened-blocked=0 routed=4 platform-unavailable=1" +} + +expect_confirmation() { + local label=$1 + local output=$2 + shift 2 + local status=0 + # The outer gate intentionally requires a TTY for one explicit RUN + # authorization. The command under test must not inherit that TTY here: + # otherwise its own confirmation prompt blocks instead of returning the + # machine-readable confirmation_required result that proves zero writes. + "$@" --format json "$output" 2>"$output.stderr" || status=$? + [[ $status -ne 0 ]] || fail "$label: unconfirmed command unexpectedly succeeded" + jq -e '.ok == false and .error.type == "validation" and .error.subtype == "confirmation_required"' "$output" >/dev/null 2>&1 || fail "$label: missing confirmation_required" +} + +run_devapp_gate() { + assert_public_surface devapp "+create ++credentials-get ++delete ++disable ++enable ++event-list ++event-subscribe ++get ++list ++member-add ++member-list ++member-remove ++permission-list ++robot-config ++robot-disable ++robot-enable ++robot-get ++update ++version-check-approval ++version-create ++version-get ++version-list ++version-status ++webapp-config ++webapp-get" + local nonce="DWS-GUARANTEED-ZERO-8F3D2C4A" + local suffix="$(date +%s)-$$" + exact_app_name="DWS-E2E-EXACT-$suffix" + raw_app_name="DWS-E2E-RAW-$suffix" + local exact_updated_name="DWS-E2E-UPDATED-$suffix" + local raw_updated_name="DWS-E2E-RAW-UPDATED-$suffix" + local bot_raw="DWS-E2E-BOT-RAW-$suffix" + local bot_exact="DWS-E2E-BOT-EXACT-$suffix" + local event_raw="bpms_task_change" + local event_exact="$event_raw" + + run_json "devapp create guard before exact query" "$evidence_dir/devapp-create-guard-before-exact.json" \ + "$dws_bin" devapp +list --name "$exact_app_name" --page-size 20 + run_json "devapp create guard before raw query" "$evidence_dir/devapp-create-guard-before-raw.json" \ + "$dws_bin" dev app list --name "$exact_app_name" --page-size 20 + expect_confirmation "devapp +create confirmation" "$evidence_dir/devapp-create-confirm.json" \ + "$dws_bin" devapp +create --name "$exact_app_name" + run_json "devapp create guard after exact query" "$evidence_dir/devapp-create-guard-after-exact.json" \ + "$dws_bin" devapp +list --name "$exact_app_name" --page-size 20 + run_json "devapp create guard after raw query" "$evidence_dir/devapp-create-guard-after-raw.json" \ + "$dws_bin" dev app list --name "$exact_app_name" --page-size 20 + assert_jq "devapp +create confirmation zero-call state" ' + (input) as $br | (input) as $ae | (input) as $ar | + .data.count == 0 and ($br.data.items | length) == 0 and + $ae.data.count == 0 and ($ar.data.items | length) == 0 + ' "$evidence_dir/devapp-create-guard-before-exact.json" "$evidence_dir/devapp-create-guard-before-raw.json" \ + "$evidence_dir/devapp-create-guard-after-exact.json" "$evidence_dir/devapp-create-guard-after-raw.json" + + run_json "devapp exact create" "$evidence_dir/devapp-create-exact.json" \ + "$dws_bin" devapp +create --name "$exact_app_name" --desc "dual-live-exact" --yes + exact_app_id=$(jq -er '.data.unifiedAppId | select(type == "string" and length > 0)' "$evidence_dir/devapp-create-exact.json") || fail "devapp exact create stable ID" + assert_jq "devapp exact create terminal receipt" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "create"' "$evidence_dir/devapp-create-exact.json" + run_json "devapp exact create raw readback" "$evidence_dir/devapp-create-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" --arg name "$exact_app_name" '.ok == true and .outcome == "success" and .data.unifiedAppId == $id and .data.name == $name' "$evidence_dir/devapp-create-exact-raw-readback.json" >/dev/null || fail "devapp exact create raw readback" + + run_json "devapp raw create" "$evidence_dir/devapp-create-raw.json" \ + "$dws_bin" dev app create --name "$raw_app_name" --desc "dual-live-raw" --yes + raw_app_id=$(jq -er '.data.unifiedAppId | select(type == "string" and length > 0)' "$evidence_dir/devapp-create-raw.json") || fail "devapp raw create stable ID" + assert_jq "devapp raw create terminal receipt" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-create-raw.json" + run_json "devapp raw create exact readback" "$evidence_dir/devapp-create-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$raw_app_id" + jq -e --arg id "$raw_app_id" --arg name "$raw_app_name" '.ok == true and .outcome == "success" and .data.unifiedAppId == $id and .data.name == $name' "$evidence_dir/devapp-create-raw-exact-readback.json" >/dev/null || fail "devapp raw create exact readback" + pass "DEVAPP +create confirm0/exact+raw terminal-receipt/stable-ID readback" + + run_json "devapp guard snapshot app" "$evidence_dir/devapp-guard-app-before.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot webapp" "$evidence_dir/devapp-guard-webapp-before.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot robot" "$evidence_dir/devapp-guard-robot-before.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot events" "$evidence_dir/devapp-guard-events-before.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp guard snapshot versions" "$evidence_dir/devapp-guard-versions-before.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + + expect_confirmation "devapp +update confirmation" "$evidence_dir/devapp-update-confirm.json" \ + "$dws_bin" devapp +update --unified-app-id "$exact_app_id" --name "guard-must-not-write" + expect_confirmation "devapp +delete confirmation" "$evidence_dir/devapp-delete-confirm.json" \ + "$dws_bin" devapp +delete --unified-app-id "$exact_app_id" + expect_confirmation "devapp +enable confirmation" "$evidence_dir/devapp-enable-confirm.json" \ + "$dws_bin" devapp +enable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +disable confirmation" "$evidence_dir/devapp-disable-confirm.json" \ + "$dws_bin" devapp +disable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +webapp-config confirmation" "$evidence_dir/devapp-webapp-confirm.json" \ + "$dws_bin" devapp +webapp-config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/guard" + expect_confirmation "devapp +robot-config confirmation" "$evidence_dir/devapp-robot-config-confirm.json" \ + "$dws_bin" devapp +robot-config --unified-app-id "$exact_app_id" --name "guard-must-not-write" --mode STREAM + expect_confirmation "devapp +robot-enable confirmation" "$evidence_dir/devapp-robot-enable-confirm.json" \ + "$dws_bin" devapp +robot-enable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +robot-disable confirmation" "$evidence_dir/devapp-robot-disable-confirm.json" \ + "$dws_bin" devapp +robot-disable --unified-app-id "$exact_app_id" + expect_confirmation "devapp +event-subscribe confirmation" "$evidence_dir/devapp-event-confirm.json" \ + "$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_exact" + expect_confirmation "devapp +version-create confirmation" "$evidence_dir/devapp-version-create-confirm.json" \ + "$dws_bin" devapp +version-create --unified-app-id "$exact_app_id" --desc "guard-must-not-write" + + run_json "devapp guard snapshot app after" "$evidence_dir/devapp-guard-app-after.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot webapp after" "$evidence_dir/devapp-guard-webapp-after.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot robot after" "$evidence_dir/devapp-guard-robot-after.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + run_json "devapp guard snapshot events after" "$evidence_dir/devapp-guard-events-after.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp guard snapshot versions after" "$evidence_dir/devapp-guard-versions-after.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + assert_jq "devapp all public write confirmations preserve raw state" ' + (input) as $aa | (input) as $wb | (input) as $wa | (input) as $rb | (input) as $ra | + (input) as $eb | (input) as $ea | (input) as $vb | (input) as $va | + [.data.unifiedAppId,.data.name,.data.desc,.data.appStatus] == [$aa.data.unifiedAppId,$aa.data.name,$aa.data.desc,$aa.data.appStatus] and + $wb.data == $wa.data and $rb.data == $ra.data and + ([$eb.data.events[].eventCode] | sort) == ([$ea.data.events[].eventCode] | sort) and + ([$vb.data.items[].versionId] | sort) == ([$va.data.items[].versionId] | sort) + ' "$evidence_dir/devapp-guard-app-before.json" "$evidence_dir/devapp-guard-app-after.json" \ + "$evidence_dir/devapp-guard-webapp-before.json" "$evidence_dir/devapp-guard-webapp-after.json" \ + "$evidence_dir/devapp-guard-robot-before.json" "$evidence_dir/devapp-guard-robot-after.json" \ + "$evidence_dir/devapp-guard-events-before.json" "$evidence_dir/devapp-guard-events-after.json" \ + "$evidence_dir/devapp-guard-versions-before.json" "$evidence_dir/devapp-guard-versions-after.json" + + run_json "devapp exact list known" "$evidence_dir/devapp-list-known-exact.json" \ + "$dws_bin" devapp +list --name "$exact_app_name" --page-size 20 + run_json "devapp raw list known" "$evidence_dir/devapp-list-known-raw.json" \ + "$dws_bin" dev app list --name "$exact_app_name" --page-size 20 + run_json "devapp exact list zero" "$evidence_dir/devapp-list-zero-exact.json" \ + "$dws_bin" devapp +list --name "$nonce" --page-size 20 + run_json "devapp raw list zero" "$evidence_dir/devapp-list-zero-raw.json" \ + "$dws_bin" dev app list --name "$nonce" --page-size 20 + jq -e --arg id "$exact_app_id" ' + (input) as $raw | (input) as $ze | (input) as $zr | + .ok == true and .outcome == "success" and .data.count == 1 and .data.apps[0].unifiedAppId == $id and + $raw.ok == true and $raw.outcome == "success" and ($raw.data.items | length) == 1 and $raw.data.items[0].unifiedAppId == $id and + $ze.ok == true and $ze.data.count == 0 and ($ze.data.apps | length) == 0 and + $zr.ok == true and ($zr.data.items | length) == 0 and + .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true and + $ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true + ' "$evidence_dir/devapp-list-known-exact.json" "$evidence_dir/devapp-list-known-raw.json" \ + "$evidence_dir/devapp-list-zero-exact.json" "$evidence_dir/devapp-list-zero-raw.json" >/dev/null || fail "devapp +list dual known/zero alignment" + pass "DEVAPP +list exact+raw known-nonempty/guaranteed-zero stable-ID/cursor termination" + + run_json "devapp exact get" "$evidence_dir/devapp-get-exact.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + run_json "devapp raw get" "$evidence_dir/devapp-get-raw.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" '(input) as $raw | .ok == true and .data.unifiedAppId == $id and $raw.ok == true and $raw.data.unifiedAppId == $id and .data.appKey == $raw.data.appKey' "$evidence_dir/devapp-get-exact.json" "$evidence_dir/devapp-get-raw.json" >/dev/null || fail "devapp +get dual object read" + pass "DEVAPP +get exact+raw stable-application-ID object alignment" + + run_json "devapp raw update" "$evidence_dir/devapp-update-raw.json" \ + "$dws_bin" dev app update --unified-app-id "$exact_app_id" --name "$raw_updated_name" --desc "raw-update" --yes + assert_jq "devapp raw update terminal receipt" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-update-raw.json" + run_json "devapp raw update exact readback" "$evidence_dir/devapp-update-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" --arg name "$raw_updated_name" '.ok == true and .data.unifiedAppId == $id and .data.name == $name and .data.desc == "raw-update"' "$evidence_dir/devapp-update-raw-exact-readback.json" >/dev/null || fail "devapp raw update exact readback" + run_json "devapp exact update" "$evidence_dir/devapp-update-exact.json" \ + "$dws_bin" devapp +update --unified-app-id "$exact_app_id" --name "$exact_updated_name" --desc "exact-update" --yes + assert_jq "devapp exact update terminal receipt" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "update"' "$evidence_dir/devapp-update-exact.json" + run_json "devapp exact update raw readback" "$evidence_dir/devapp-update-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" --arg name "$exact_updated_name" '.ok == true and .data.unifiedAppId == $id and .data.name == $name and .data.desc == "exact-update"' "$evidence_dir/devapp-update-exact-raw-readback.json" >/dev/null || fail "devapp exact update raw readback" + pass "DEVAPP +update confirm0/exact+raw terminal-receipt/key-field readback" + + run_json "devapp raw disable" "$evidence_dir/devapp-disable-raw.json" \ + "$dws_bin" dev app disable --unified-app-id "$exact_app_id" --yes + run_json "devapp raw disable exact readback" "$evidence_dir/devapp-disable-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + assert_jq "devapp raw disable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-disable-raw.json" + assert_jq "devapp raw disable exact state" '(.data.appStatus | ascii_downcase) == "disabled"' "$evidence_dir/devapp-disable-raw-exact-readback.json" + run_json "devapp exact enable" "$evidence_dir/devapp-enable-exact.json" \ + "$dws_bin" devapp +enable --unified-app-id "$exact_app_id" --yes + run_json "devapp exact enable raw readback" "$evidence_dir/devapp-enable-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + assert_jq "devapp exact enable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-enable-exact.json" + assert_jq "devapp exact enable raw state" '(.data.appStatus | ascii_downcase) == "normal"' "$evidence_dir/devapp-enable-exact-raw-readback.json" + run_json "devapp exact disable" "$evidence_dir/devapp-disable-exact.json" \ + "$dws_bin" devapp +disable --unified-app-id "$exact_app_id" --yes + run_json "devapp exact disable raw readback" "$evidence_dir/devapp-disable-exact-raw-readback.json" \ + "$dws_bin" dev app get --unified-app-id "$exact_app_id" + assert_jq "devapp exact disable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-disable-exact.json" + assert_jq "devapp exact disable raw state" '(.data.appStatus | ascii_downcase) == "disabled"' "$evidence_dir/devapp-disable-exact-raw-readback.json" + run_json "devapp raw enable" "$evidence_dir/devapp-enable-raw.json" \ + "$dws_bin" dev app enable --unified-app-id "$exact_app_id" --yes + run_json "devapp raw enable exact readback" "$evidence_dir/devapp-enable-raw-exact-readback.json" \ + "$dws_bin" devapp +get --unified-app-id "$exact_app_id" + assert_jq "devapp raw enable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-enable-raw.json" + assert_jq "devapp raw enable exact state" '(.data.appStatus | ascii_downcase) == "normal"' "$evidence_dir/devapp-enable-raw-exact-readback.json" + pass "DEVAPP +disable confirm0/exact+raw terminal-receipt/disabled readback/restored" + pass "DEVAPP +enable confirm0/exact+raw terminal-receipt/normal readback" + + run_json "devapp exact credentials" "$evidence_dir/devapp-credentials-exact.json" \ + "$dws_bin" devapp +credentials-get --unified-app-id "$exact_app_id" + run_json "devapp raw credentials" "$evidence_dir/devapp-credentials-raw.json" \ + "$dws_bin" dev app credentials get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.unifiedAppId == $id and + (.data.appKey | type) == "string" and (.data.appKey | length) > 0 and + (.data.appSecret | type) == "string" and (.data.appSecret | length) > 0 and + $raw.ok == true and $raw.data.unifiedAppId == $id and + .data.appKey == $raw.data.appKey and .data.appSecret == $raw.data.appSecret + ' "$evidence_dir/devapp-credentials-exact.json" "$evidence_dir/devapp-credentials-raw.json" >/dev/null || fail "devapp +credentials-get dual object read" + pass "DEVAPP +credentials-get exact+raw stable-app/client/one-nonempty-secret alignment" + + run_json "devapp raw webapp config" "$evidence_dir/devapp-webapp-config-raw.json" \ + "$dws_bin" dev app webapp config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/raw" --yes + run_json "devapp raw webapp exact readback" "$evidence_dir/devapp-webapp-config-raw-exact-readback.json" \ + "$dws_bin" devapp +webapp-get --unified-app-id "$exact_app_id" + assert_jq "devapp raw webapp terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-webapp-config-raw.json" + assert_jq "devapp raw webapp exact readback" '.data.homepageUrl == "https://example.invalid/raw"' "$evidence_dir/devapp-webapp-config-raw-exact-readback.json" + run_json "devapp exact webapp config" "$evidence_dir/devapp-webapp-config-exact.json" \ + "$dws_bin" devapp +webapp-config --unified-app-id "$exact_app_id" --homepage-url "https://example.invalid/exact" --yes + run_json "devapp exact webapp raw readback" "$evidence_dir/devapp-webapp-config-exact-raw-readback.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + assert_jq "devapp exact webapp terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-webapp-config-exact.json" + assert_jq "devapp exact webapp raw readback" '.data.homepageUrl == "https://example.invalid/exact"' "$evidence_dir/devapp-webapp-config-exact-raw-readback.json" + pass "DEVAPP +webapp-config confirm0/exact+raw terminal-receipt/URL readback" + run_json "devapp exact webapp get final" "$evidence_dir/devapp-webapp-get-exact.json" \ + "$dws_bin" devapp +webapp-get --unified-app-id "$exact_app_id" + run_json "devapp raw webapp get final" "$evidence_dir/devapp-webapp-get-raw.json" \ + "$dws_bin" dev app webapp get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" '(input) as $raw | .data.unifiedAppId == $id and $raw.data.unifiedAppId == $id and .data.homepageUrl == $raw.data.homepageUrl' "$evidence_dir/devapp-webapp-get-exact.json" "$evidence_dir/devapp-webapp-get-raw.json" >/dev/null || fail "devapp +webapp-get dual object read" + pass "DEVAPP +webapp-get exact+raw stable-app/key-field object alignment" + + run_json "devapp member raw known" "$evidence_dir/devapp-member-known-raw.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + run_json "devapp member exact known" "$evidence_dir/devapp-member-known-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" + run_json "devapp member exact zero" "$evidence_dir/devapp-member-zero-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$nonce" + jq -e --arg nonce "$nonce" ' + (input) as $raw | (input) as $zero | + .ok == true and .data.count > 0 and $raw.ok == true and ($raw.data.members | type) == "array" and + ([.data.members[].userId] | sort) == ([$raw.data.members[].userId] | sort) and + $zero.ok == true and $zero.data.count == 0 and ($zero.data.members | length) == 0 and + ([$raw.data.members[].userId] | index($nonce)) == null + ' "$evidence_dir/devapp-member-known-exact.json" "$evidence_dir/devapp-member-known-raw.json" "$evidence_dir/devapp-member-zero-exact.json" >/dev/null || fail "devapp +member-list adapter alignment" + pass "DEVAPP +member-list exact local-ID-filter+raw full-set known/guaranteed-zero stable-member alignment" + + run_json "devapp member fixture exact before" "$evidence_dir/devapp-member-fixture-before-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + run_json "devapp member fixture raw before" "$evidence_dir/devapp-member-fixture-before-raw.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + jq -e --arg user "$member_user_id" '(input) as $raw | .data.count == 0 and (.data.members | length) == 0 and ([$raw.data.members[].userId] | index($user)) == null' \ + "$evidence_dir/devapp-member-fixture-before-exact.json" "$evidence_dir/devapp-member-fixture-before-raw.json" >/dev/null || fail "devapp member fixture initially absent" + + expect_confirmation "devapp +member-add confirmation" "$evidence_dir/devapp-member-add-confirm.json" \ + "$dws_bin" devapp +member-add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" + expect_confirmation "devapp +member-remove confirmation" "$evidence_dir/devapp-member-remove-confirm.json" \ + "$dws_bin" devapp +member-remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" + run_json "devapp member fixture exact after guards" "$evidence_dir/devapp-member-fixture-after-guards-exact.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + run_json "devapp member fixture raw after guards" "$evidence_dir/devapp-member-fixture-after-guards-raw.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + jq -e --arg user "$member_user_id" '(input) as $raw | .data.count == 0 and (.data.members | length) == 0 and ([$raw.data.members[].userId] | index($user)) == null' \ + "$evidence_dir/devapp-member-fixture-after-guards-exact.json" "$evidence_dir/devapp-member-fixture-after-guards-raw.json" >/dev/null || fail "devapp member confirmations made no writes" + + run_json "devapp member exact add" "$evidence_dir/devapp-member-add-exact.json" \ + "$dws_bin" devapp +member-add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member exact add raw readback" "$evidence_dir/devapp-member-add-exact-raw-readback.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + jq -e --arg type "$member_type" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "member_add" and .data.resource.count == 1 and .data.resource.memberType == $type' \ + "$evidence_dir/devapp-member-add-exact.json" >/dev/null || fail "devapp member exact add terminal" + jq -e --arg user "$member_user_id" --arg type "$member_type" '([.data.members[] | select(.userId == $user and .memberType == $type)] | length) == 1' \ + "$evidence_dir/devapp-member-add-exact-raw-readback.json" >/dev/null || fail "devapp member exact add raw stable-ID role readback" + + run_json "devapp member raw remove" "$evidence_dir/devapp-member-remove-raw.json" \ + "$dws_bin" dev app member remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member raw remove exact readback" "$evidence_dir/devapp-member-remove-raw-exact-readback.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + assert_jq "devapp member raw remove terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-member-remove-raw.json" + assert_jq "devapp member raw remove exact absence" '.ok == true and .data.count == 0 and (.data.members | length) == 0' "$evidence_dir/devapp-member-remove-raw-exact-readback.json" + + run_json "devapp member raw add" "$evidence_dir/devapp-member-add-raw.json" \ + "$dws_bin" dev app member add --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member raw add exact readback" "$evidence_dir/devapp-member-add-raw-exact-readback.json" \ + "$dws_bin" devapp +member-list --unified-app-id "$exact_app_id" --user-id "$member_user_id" + assert_jq "devapp member raw add terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-member-add-raw.json" + jq -e --arg user "$member_user_id" --arg type "$member_type" \ + '.ok == true and .data.count == 1 and .data.members[0].userId == $user and .data.members[0].memberType == $type' \ + "$evidence_dir/devapp-member-add-raw-exact-readback.json" >/dev/null || fail "devapp member raw add exact stable-ID role readback" + + run_json "devapp member exact remove" "$evidence_dir/devapp-member-remove-exact.json" \ + "$dws_bin" devapp +member-remove --unified-app-id "$exact_app_id" --user-ids "$member_user_id" --member-type "$member_type" --yes + run_json "devapp member exact remove raw readback" "$evidence_dir/devapp-member-remove-exact-raw-readback.json" \ + "$dws_bin" dev app member list --unified-app-id "$exact_app_id" + assert_jq "devapp member exact remove terminal" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "member_remove"' \ + "$evidence_dir/devapp-member-remove-exact.json" + jq -e --arg user "$member_user_id" '([.data.members[].userId] | index($user)) == null' \ + "$evidence_dir/devapp-member-remove-exact-raw-readback.json" >/dev/null || fail "devapp member exact remove raw absence" + pass "DEVAPP +member-add/+member-remove confirm0/exact+raw terminal-receipt/stable-ID role/absence readback" + + run_json "devapp permission exact known" "$evidence_dir/devapp-permission-known-exact.json" \ + "$dws_bin" devapp +permission-list --unified-app-id "$exact_app_id" --page-size 20 + run_json "devapp permission raw known" "$evidence_dir/devapp-permission-known-raw.json" \ + "$dws_bin" dev app permission list --unified-app-id "$exact_app_id" --page-size 20 + run_json "devapp permission exact zero" "$evidence_dir/devapp-permission-zero-exact.json" \ + "$dws_bin" devapp +permission-list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 20 + run_json "devapp permission raw zero" "$evidence_dir/devapp-permission-zero-raw.json" \ + "$dws_bin" dev app permission list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 20 + assert_jq "devapp permission dual known/zero alignment" ' + (input) as $raw | (input) as $ze | (input) as $zr | + .ok == true and .data.count > 0 and $raw.ok == true and ($raw.data.items | length) > 0 and + ([.data.permissions[].scopeValue] | sort) == ([$raw.data.items[].scopeValue] | sort) and + $ze.ok == true and $ze.data.count == 0 and ($ze.data.permissions | length) == 0 and + $zr.ok == true and ($zr.data.items | length) == 0 and + .meta.pagination.endpoint_exhausted == $raw.meta.pagination.endpoint_exhausted and + $ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true + ' "$evidence_dir/devapp-permission-known-exact.json" "$evidence_dir/devapp-permission-known-raw.json" \ + "$evidence_dir/devapp-permission-zero-exact.json" "$evidence_dir/devapp-permission-zero-raw.json" + pass "DEVAPP +permission-list exact+raw known-nonempty/guaranteed-zero stable-scope/cursor alignment" + + run_json "devapp robot exact initial" "$evidence_dir/devapp-robot-get-exact.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + run_json "devapp robot raw initial" "$evidence_dir/devapp-robot-get-raw.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + jq -e --arg id "$exact_app_id" '(input) as $raw | .data.unifiedAppId == $id and $raw.data.unifiedAppId == $id and .data.robotStatus == $raw.data.robotStatus' "$evidence_dir/devapp-robot-get-exact.json" "$evidence_dir/devapp-robot-get-raw.json" >/dev/null || fail "devapp +robot-get initial dual object" + pass "DEVAPP +robot-get exact+raw stable-app/status object alignment" + + run_json "devapp robot raw config" "$evidence_dir/devapp-robot-config-raw.json" \ + "$dws_bin" dev app robot config --unified-app-id "$exact_app_id" --name "$bot_raw" --brief "dual-live-raw" --desc "dual-live-raw" --mode STREAM --add-scope --yes + run_json "devapp robot raw config exact readback" "$evidence_dir/devapp-robot-config-raw-exact-readback.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + assert_jq "devapp robot raw config terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-config-raw.json" + jq -e --arg name "$bot_raw" '.data.name == $name and .data.mode == "STREAM"' "$evidence_dir/devapp-robot-config-raw-exact-readback.json" >/dev/null || fail "devapp robot raw config exact readback" + run_json "devapp robot exact config" "$evidence_dir/devapp-robot-config-exact.json" \ + "$dws_bin" devapp +robot-config --unified-app-id "$exact_app_id" --name "$bot_exact" --brief "dual-live-exact" --desc "dual-live-exact" --mode STREAM --yes + run_json "devapp robot exact config raw readback" "$evidence_dir/devapp-robot-config-exact-raw-readback.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + assert_jq "devapp robot exact config terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-config-exact.json" + jq -e --arg name "$bot_exact" '.data.name == $name and .data.mode == "STREAM"' "$evidence_dir/devapp-robot-config-exact-raw-readback.json" >/dev/null || fail "devapp robot exact config raw readback" + pass "DEVAPP +robot-config confirm0/exact+raw terminal-receipt/config-field readback" + + run_json "devapp robot raw enable" "$evidence_dir/devapp-robot-enable-raw.json" \ + "$dws_bin" dev app robot enable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot raw enable exact readback" "$evidence_dir/devapp-robot-enable-raw-exact-readback.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + assert_jq "devapp robot raw enable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-enable-raw.json" + assert_jq "devapp robot raw enable exact state" '.data.robotStatus == "ONLINE"' "$evidence_dir/devapp-robot-enable-raw-exact-readback.json" + run_json "devapp robot exact disable" "$evidence_dir/devapp-robot-disable-exact.json" \ + "$dws_bin" devapp +robot-disable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot exact disable raw readback" "$evidence_dir/devapp-robot-disable-exact-raw-readback.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + assert_jq "devapp robot exact disable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-disable-exact.json" + assert_jq "devapp robot exact disable raw state" '.data.robotStatus == "UNCONFIGURED"' "$evidence_dir/devapp-robot-disable-exact-raw-readback.json" + run_json "devapp robot raw reconfigure" "$evidence_dir/devapp-robot-reconfigure-raw.json" \ + "$dws_bin" dev app robot config --unified-app-id "$exact_app_id" --name "$bot_raw" --brief "dual-live-raw" --mode STREAM --yes + run_json "devapp robot exact enable" "$evidence_dir/devapp-robot-enable-exact.json" \ + "$dws_bin" devapp +robot-enable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot exact enable raw readback" "$evidence_dir/devapp-robot-enable-exact-raw-readback.json" \ + "$dws_bin" dev app robot get --unified-app-id "$exact_app_id" + assert_jq "devapp robot exact enable terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-robot-enable-exact.json" + assert_jq "devapp robot exact enable raw state" '.data.robotStatus == "ONLINE"' "$evidence_dir/devapp-robot-enable-exact-raw-readback.json" + run_json "devapp robot raw disable" "$evidence_dir/devapp-robot-disable-raw.json" \ + "$dws_bin" dev app robot disable --unified-app-id "$exact_app_id" --yes + run_json "devapp robot raw disable exact readback" "$evidence_dir/devapp-robot-disable-raw-exact-readback.json" \ + "$dws_bin" devapp +robot-get --unified-app-id "$exact_app_id" + assert_jq "devapp robot raw disable terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-robot-disable-raw.json" + assert_jq "devapp robot raw disable exact state" '.data.robotStatus == "UNCONFIGURED"' "$evidence_dir/devapp-robot-disable-raw-exact-readback.json" + pass "DEVAPP +robot-enable confirm0/exact+raw terminal-receipt/ONLINE readback" + pass "DEVAPP +robot-disable confirm0/exact+raw terminal-receipt/UNCONFIGURED readback" + + run_json "devapp event raw subscribe" "$evidence_dir/devapp-event-subscribe-raw.json" \ + "$dws_bin" dev app event subscribe --unified-app-id "$exact_app_id" --event-codes "$event_raw" --yes + run_json "devapp event raw subscribe exact replay" "$evidence_dir/devapp-event-subscribe-raw-exact-replay.json" \ + "$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_raw" --yes + run_json "devapp event raw subscribe raw readback" "$evidence_dir/devapp-event-subscribe-raw-readback.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_raw" --page-size 100 + assert_jq "devapp event raw subscribe terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-event-subscribe-raw.json" + assert_jq "devapp event raw subscribe exact replay" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-event-subscribe-raw-exact-replay.json" + jq -e --arg code "$event_raw" '(.data.events | length) == 1 and .data.events[0].eventCode == $code' "$evidence_dir/devapp-event-subscribe-raw-readback.json" >/dev/null || fail "devapp event raw subscribe readback" + run_json "devapp event exact subscribe" "$evidence_dir/devapp-event-subscribe-exact.json" \ + "$dws_bin" devapp +event-subscribe --unified-app-id "$exact_app_id" --event-codes "$event_exact" --yes + run_json "devapp event exact subscribe raw readback" "$evidence_dir/devapp-event-subscribe-exact-raw-readback.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100 + assert_jq "devapp event exact subscribe terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-event-subscribe-exact.json" + jq -e --arg code "$event_exact" '(.data.events | length) == 1 and .data.events[0].eventCode == $code' "$evidence_dir/devapp-event-subscribe-exact-raw-readback.json" >/dev/null || fail "devapp event exact subscribe raw readback" + pass "DEVAPP +event-subscribe confirm0/exact+raw terminal-receipt/eventCode readback" + + run_json "devapp event exact known" "$evidence_dir/devapp-event-list-known-exact.json" \ + "$dws_bin" devapp +event-list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100 + run_json "devapp event raw known" "$evidence_dir/devapp-event-list-known-raw.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$event_exact" --page-size 100 + run_json "devapp event exact zero" "$evidence_dir/devapp-event-list-zero-exact.json" \ + "$dws_bin" devapp +event-list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 100 + run_json "devapp event raw zero" "$evidence_dir/devapp-event-list-zero-raw.json" \ + "$dws_bin" dev app event list --unified-app-id "$exact_app_id" --keyword "$nonce" --page-size 100 + jq -e --arg code "$event_exact" '(input) as $raw | (input) as $ze | (input) as $zr | + .ok == true and .data.count == 1 and .data.events[0].eventCode == $code and + $raw.ok == true and ($raw.data.events | length) == 1 and $raw.data.events[0].eventCode == $code and + .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true and + $ze.ok == true and $ze.data.count == 0 and ($ze.data.events | length) == 0 and + $zr.ok == true and ($zr.data.events | length) == 0 and + $ze.meta.pagination.endpoint_exhausted == true and $zr.meta.pagination.endpoint_exhausted == true' \ + "$evidence_dir/devapp-event-list-known-exact.json" "$evidence_dir/devapp-event-list-known-raw.json" \ + "$evidence_dir/devapp-event-list-zero-exact.json" "$evidence_dir/devapp-event-list-zero-raw.json" >/dev/null || fail "devapp +event-list dual known/zero alignment" + pass "DEVAPP +event-list exact+raw known-nonempty/guaranteed-zero stable-event/cursor alignment" + + run_json "devapp version exact zero before" "$evidence_dir/devapp-version-zero-exact.json" \ + "$dws_bin" devapp +version-list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp version raw zero before" "$evidence_dir/devapp-version-zero-raw.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + assert_jq "devapp version guaranteed zero fixture" '(input) as $raw | .data.count == 0 and (.data.versions | length) == 0 and ($raw.data.items | length) == 0 and .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true' "$evidence_dir/devapp-version-zero-exact.json" "$evidence_dir/devapp-version-zero-raw.json" + run_json "devapp version raw create" "$evidence_dir/devapp-version-create-raw.json" \ + "$dws_bin" dev app version create --unified-app-id "$exact_app_id" --desc "dual-live-raw" --yes + local raw_version_id + raw_version_id=$(jq -er '.data.versionId | select(type == "string" and length > 0)' "$evidence_dir/devapp-version-create-raw.json") || fail "devapp raw version create stable ID" + assert_jq "devapp raw version create terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-version-create-raw.json" + run_json "devapp raw version exact readback" "$evidence_dir/devapp-version-create-raw-exact-readback.json" \ + "$dws_bin" devapp +version-get --unified-app-id "$exact_app_id" --version-id "$raw_version_id" + jq -e --arg app "$exact_app_id" --arg version "$raw_version_id" '.ok == true and .data.unifiedAppId == $app and .data.versionId == $version' "$evidence_dir/devapp-version-create-raw-exact-readback.json" >/dev/null || fail "devapp raw version exact readback" + run_json "devapp version exact create" "$evidence_dir/devapp-version-create-exact.json" \ + "$dws_bin" devapp +version-create --unified-app-id "$exact_app_id" --desc "dual-live-exact" --yes + local exact_version_id + exact_version_id=$(jq -er '.data.resource.versionId | select(type == "string" and length > 0)' "$evidence_dir/devapp-version-create-exact.json") || fail "devapp exact version create stable ID" + assert_jq "devapp exact version create terminal" '.ok == true and .outcome == "success" and .data.verified == true' "$evidence_dir/devapp-version-create-exact.json" + run_json "devapp exact version raw readback" "$evidence_dir/devapp-version-create-exact-raw-readback.json" \ + "$dws_bin" dev app version get --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '.ok == true and .data.unifiedAppId == $app and .data.versionId == $version' "$evidence_dir/devapp-version-create-exact-raw-readback.json" >/dev/null || fail "devapp exact version raw readback" + pass "DEVAPP +version-create confirm0/exact+raw terminal-receipt/app+version-ID readback" + + local version_aligned=false + for _ in 1 2 3 4 5; do + run_json "devapp version exact known" "$evidence_dir/devapp-version-known-exact.json" \ + "$dws_bin" devapp +version-list --unified-app-id "$exact_app_id" --page-size 100 + run_json "devapp version raw known" "$evidence_dir/devapp-version-known-raw.json" \ + "$dws_bin" dev app version list --unified-app-id "$exact_app_id" --page-size 100 + if jq -e --arg raw_version "$raw_version_id" --arg exact_version "$exact_version_id" ' + (input) as $raw | + .data.count >= 1 and + ([.data.versions[].versionId] | index($raw_version)) != null and + ([.data.versions[].versionId] | index($exact_version)) != null and + ([.data.versions[].versionId] | sort) == ([$raw.data.items[].versionId] | sort) and + .meta.pagination.endpoint_exhausted == true and $raw.meta.pagination.endpoint_exhausted == true + ' "$evidence_dir/devapp-version-known-exact.json" "$evidence_dir/devapp-version-known-raw.json" >/dev/null 2>&1; then + version_aligned=true + break + fi + sleep 2 + done + if [[ $version_aligned != true ]]; then + jq -n -c --arg raw_version "$raw_version_id" --arg exact_version "$exact_version_id" \ + --slurpfile exact "$evidence_dir/devapp-version-known-exact.json" \ + --slurpfile raw "$evidence_dir/devapp-version-known-raw.json" '{ + diagnostic:"DEVAPP-VERSION-LIST", + exact_count:$exact[0].data.count, + exact_items:($exact[0].data.versions|length), + raw_items:($raw[0].data.items|length), + ids_equal:(([$exact[0].data.versions[].versionId]|sort)==([$raw[0].data.items[].versionId]|sort)), + raw_version_visible:(([$exact[0].data.versions[].versionId]|index($raw_version))!=null), + exact_version_visible:(([$exact[0].data.versions[].versionId]|index($exact_version))!=null), + exact_pagination_type:($exact[0].meta.pagination|type), + raw_pagination_type:($raw[0].meta.pagination|type), + exact_exhausted:$exact[0].meta.pagination.endpoint_exhausted, + raw_exhausted:$raw[0].meta.pagination.endpoint_exhausted + }' >&2 + fail "devapp version known alignment" + fi + pass "DEVAPP +version-list exact+raw known-nonempty/empty-fixture-zero stable-version/cursor alignment" + + run_json "devapp version exact get" "$evidence_dir/devapp-version-get-exact.json" \ + "$dws_bin" devapp +version-get --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + run_json "devapp version raw get" "$evidence_dir/devapp-version-get-raw.json" \ + "$dws_bin" dev app version get --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | .data.unifiedAppId == $app and .data.versionId == $version and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-get-exact.json" "$evidence_dir/devapp-version-get-raw.json" >/dev/null || fail "devapp +version-get dual object" + pass "DEVAPP +version-get exact+raw stable-app+version-ID object alignment" + + run_json "devapp version exact approval" "$evidence_dir/devapp-version-approval-exact.json" \ + "$dws_bin" devapp +version-check-approval --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + run_json "devapp version raw approval" "$evidence_dir/devapp-version-approval-raw.json" \ + "$dws_bin" dev app version check-approval --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | (.outcome == "success" or .outcome == "pending") and .data.unifiedAppId == $app and .data.versionId == $version and ($raw.outcome == "success" or $raw.outcome == "pending") and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-approval-exact.json" "$evidence_dir/devapp-version-approval-raw.json" >/dev/null || fail "devapp +version-check-approval dual object" + pass "DEVAPP +version-check-approval exact+raw stable-app+version-ID pending/terminal alignment" + + run_json "devapp version exact status" "$evidence_dir/devapp-version-status-exact.json" \ + "$dws_bin" devapp +version-status --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + run_json "devapp version raw status" "$evidence_dir/devapp-version-status-raw.json" \ + "$dws_bin" dev app version status --unified-app-id "$exact_app_id" --version-id "$exact_version_id" + jq -e --arg app "$exact_app_id" --arg version "$exact_version_id" '(input) as $raw | (.outcome == "success" or .outcome == "pending") and .data.unifiedAppId == $app and .data.versionId == $version and ($raw.outcome == "success" or $raw.outcome == "pending") and $raw.data.unifiedAppId == $app and $raw.data.versionId == $version' "$evidence_dir/devapp-version-status-exact.json" "$evidence_dir/devapp-version-status-raw.json" >/dev/null || fail "devapp +version-status dual object" + pass "DEVAPP +version-status exact+raw stable-app+version-ID status alignment" + + run_json "devapp raw delete" "$evidence_dir/devapp-delete-raw.json" \ + "$dws_bin" dev app delete --unified-app-id "$raw_app_id" --confirm-name "$raw_app_name" --yes + assert_jq "devapp raw delete terminal" '.ok == true and .outcome == "success"' "$evidence_dir/devapp-delete-raw.json" + run_json "devapp raw delete exact absence" "$evidence_dir/devapp-delete-raw-exact-absence.json" \ + "$dws_bin" devapp +list --name "$raw_app_name" --page-size 20 + run_json "devapp raw delete raw absence" "$evidence_dir/devapp-delete-raw-raw-absence.json" \ + "$dws_bin" dev app list --name "$raw_app_name" --page-size 20 + assert_jq "devapp raw delete dual absence" '(input) as $raw | .data.count == 0 and (.data.apps | length) == 0 and ($raw.data.items | length) == 0' "$evidence_dir/devapp-delete-raw-exact-absence.json" "$evidence_dir/devapp-delete-raw-raw-absence.json" + raw_app_id="" + + run_json "devapp exact delete" "$evidence_dir/devapp-delete-exact.json" \ + "$dws_bin" devapp +delete --unified-app-id "$exact_app_id" --yes + assert_jq "devapp exact delete terminal" '.ok == true and .outcome == "success" and .data.verified == true and .data.action == "delete"' "$evidence_dir/devapp-delete-exact.json" + run_json "devapp exact delete exact absence" "$evidence_dir/devapp-delete-exact-exact-absence.json" \ + "$dws_bin" devapp +list --name "$exact_updated_name" --page-size 20 + run_json "devapp exact delete raw absence" "$evidence_dir/devapp-delete-exact-raw-absence.json" \ + "$dws_bin" dev app list --name "$exact_updated_name" --page-size 20 + assert_jq "devapp exact delete dual absence" '(input) as $raw | .data.count == 0 and (.data.apps | length) == 0 and ($raw.data.items | length) == 0' "$evidence_dir/devapp-delete-exact-exact-absence.json" "$evidence_dir/devapp-delete-exact-raw-absence.json" + exact_app_id="" + pass "DEVAPP +delete confirm0/exact+raw terminal-receipt/dual absence cleanup" + + pass "DEVAPP-INVENTORY public=25 dual-layer-pass=25 unavailable=5 cleanup=zero-residual" +} + +run_agoal_gate() { + assert_public_surface agoal "+contract-fields ++obj-template-list ++report-statistics-list ++report-submit-detail ++user-rules" + local nonce="DWS-GUARANTEED-ZERO-8F3D2C4A" + local future_date="2099-12-31" + + run_json "agoal report statistics exact known" "$evidence_dir/agoal-report-known-exact.json" \ + "$dws_bin" agoal +report-statistics-list + run_json "agoal report statistics raw known" "$evidence_dir/agoal-report-known-raw.json" \ + "$dws_bin" agoal report list-statistics + assert_jq "agoal report statistics known alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count > 0 and + $raw.success == true and ($raw.content | type) == "array" and + (.data.count == ($raw.content | length)) and + ([.data.statistics[].templateId] | sort) == ([$raw.content[].templateId] | sort) + ' "$evidence_dir/agoal-report-known-exact.json" "$evidence_dir/agoal-report-known-raw.json" + run_json "agoal report statistics exact zero" "$evidence_dir/agoal-report-zero-exact.json" \ + "$dws_bin" agoal +report-statistics-list --keyword "$nonce" + run_json "agoal report statistics raw zero" "$evidence_dir/agoal-report-zero-raw.json" \ + "$dws_bin" agoal report list-statistics --keyword "$nonce" + assert_jq "agoal report statistics zero alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 0 and + (.data.statistics | type) == "array" and (.data.statistics | length) == 0 and + $raw.success == true and ($raw.content | type) == "array" and ($raw.content | length) == 0 + ' "$evidence_dir/agoal-report-zero-exact.json" "$evidence_dir/agoal-report-zero-raw.json" + pass "AGOAL +report-statistics-list exact+raw known-nonempty/guaranteed-zero stable-template alignment" + + run_json "agoal template exact known" "$evidence_dir/agoal-template-known-exact.json" \ + "$dws_bin" agoal +obj-template-list --page 1 --page-size 20 + run_json "agoal template raw known" "$evidence_dir/agoal-template-known-raw.json" \ + "$dws_bin" agoal obj-template list --page 1 --page-size 20 + assert_jq "agoal template known alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count > 0 and + $raw.success == true and ($raw.content.result | type) == "array" and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize and + .data.totalCount == $raw.content.totalCount and + ([.data.templates[] | (.id // .templateId)] | sort) == + ([$raw.content.result[] | (.id // .templateId)] | sort) + ' "$evidence_dir/agoal-template-known-exact.json" "$evidence_dir/agoal-template-known-raw.json" + run_json "agoal template exact zero" "$evidence_dir/agoal-template-zero-exact.json" \ + "$dws_bin" agoal +obj-template-list --keyword "$nonce" --page 1 --page-size 20 + run_json "agoal template raw zero" "$evidence_dir/agoal-template-zero-raw.json" \ + "$dws_bin" agoal obj-template list --keyword "$nonce" --page 1 --page-size 20 + assert_jq "agoal template zero alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 0 and .data.totalCount == 0 and + (.data.templates | type) == "array" and (.data.templates | length) == 0 and + $raw.success == true and ($raw.content.result | type) == "array" and + ($raw.content.result | length) == 0 and $raw.content.totalCount == 0 and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize + ' "$evidence_dir/agoal-template-zero-exact.json" "$evidence_dir/agoal-template-zero-raw.json" + pass "AGOAL +obj-template-list exact+raw known-nonempty/guaranteed-zero stable-ID/page alignment" + + run_json "agoal contract fields raw" "$evidence_dir/agoal-fields-raw.json" \ + "$dws_bin" agoal contract fields + local field_id + field_id=$(jq -er '.content[0].id | select(type == "string" and length > 0)' "$evidence_dir/agoal-fields-raw.json") || fail "agoal contract fields raw fixture" + run_json "agoal contract fields exact known" "$evidence_dir/agoal-fields-known-exact.json" \ + "$dws_bin" agoal +contract-fields --keyword "$field_id" + run_json "agoal contract fields exact zero" "$evidence_dir/agoal-fields-zero-exact.json" \ + "$dws_bin" agoal +contract-fields --keyword "$nonce" + jq -e --arg field_id "$field_id" --arg nonce "$nonce" ' + (input) as $zero | (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 1 and + .data.fields[0].id == $field_id and + $zero.ok == true and $zero.outcome == "success" and $zero.data.count == 0 and + ($zero.data.fields | length) == 0 and + $raw.success == true and ($raw.content | type) == "array" and ($raw.content | length) > 0 and + ([$raw.content[].id] | index($field_id)) != null and + ([$raw.content[] | [.id,.code,.title,.category,.type] | map(select(type == "string")) | join(" ") | ascii_downcase] | + map(contains($nonce | ascii_downcase)) | any) == false + ' "$evidence_dir/agoal-fields-known-exact.json" "$evidence_dir/agoal-fields-zero-exact.json" "$evidence_dir/agoal-fields-raw.json" >/dev/null 2>&1 || fail "agoal contract fields adapter alignment" + pass "AGOAL +contract-fields exact local-filter+raw full-set known/guaranteed-zero stable-ID alignment" + + run_json "agoal user rules raw" "$evidence_dir/agoal-rules-raw.json" \ + "$dws_bin" agoal user rules + local rule_id + rule_id=$(jq -er '.content.rules[0].id | select(type == "string" and length > 0)' "$evidence_dir/agoal-rules-raw.json") || fail "agoal user rules raw fixture" + run_json "agoal user rules exact known" "$evidence_dir/agoal-rules-known-exact.json" \ + "$dws_bin" agoal +user-rules --rule-id "$rule_id" + run_json "agoal user rules exact zero" "$evidence_dir/agoal-rules-zero-exact.json" \ + "$dws_bin" agoal +user-rules --rule-id "$nonce" + jq -e --arg rule_id "$rule_id" --arg nonce "$nonce" ' + (input) as $zero | (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 1 and .data.rules[0].id == $rule_id and + $zero.ok == true and $zero.outcome == "success" and $zero.data.count == 0 and + ($zero.data.rules | length) == 0 and + $raw.success == true and ($raw.content.rules | type) == "array" and ($raw.content.rules | length) > 0 and + ([$raw.content.rules[].id] | index($rule_id)) != null and + ([$raw.content.rules[].id] | index($nonce)) == null + ' "$evidence_dir/agoal-rules-known-exact.json" "$evidence_dir/agoal-rules-zero-exact.json" "$evidence_dir/agoal-rules-raw.json" >/dev/null 2>&1 || fail "agoal user rules adapter alignment" + pass "AGOAL +user-rules exact local-filter+raw full-set known/guaranteed-zero stable-rule alignment" + + local template_id="" + local submit_state="" + while IFS= read -r candidate_template; do + for candidate_state in ON_TIME LATE NOT_SUBMITTED; do + run_json "agoal submission raw fixture scan" "$evidence_dir/agoal-submit-scan.json" \ + "$dws_bin" agoal report submit-detail --template-id "$candidate_template" --submit-state "$candidate_state" --page 1 --page-size 20 --timeout 60 + if jq -e '.success == true and (.content.result | type) == "array" and (.content.result | length) > 0' "$evidence_dir/agoal-submit-scan.json" >/dev/null; then + template_id=$candidate_template + submit_state=$candidate_state + cp "$evidence_dir/agoal-submit-scan.json" "$evidence_dir/agoal-submit-known-raw.json" + break 2 + fi + done + done < <(jq -r '.content[].templateId' "$evidence_dir/agoal-report-known-raw.json") + [[ -n $template_id && -n $submit_state ]] || fail "agoal submission known-nonempty fixture" + run_json "agoal submission exact known" "$evidence_dir/agoal-submit-known-exact.json" \ + "$dws_bin" agoal +report-submit-detail --template-id "$template_id" --submit-state "$submit_state" --page 1 --page-size 20 --timeout 60 + assert_jq "agoal submission known alignment" ' + (input) as $raw | + def stable_id: (.reportId // .user.dingUserId // .user.id); + .ok == true and .outcome == "success" and .data.count > 0 and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize and + .data.totalCount == $raw.content.totalCount and + ([.data.submissions[] | stable_id] | sort) == ([$raw.content.result[] | stable_id] | sort) + ' "$evidence_dir/agoal-submit-known-exact.json" "$evidence_dir/agoal-submit-known-raw.json" + run_json "agoal submission exact guaranteed zero" "$evidence_dir/agoal-submit-zero-exact.json" \ + "$dws_bin" agoal +report-submit-detail --template-id "$template_id" --submit-state ON_TIME --query-date "$future_date" --page 1 --page-size 20 --timeout 60 + run_json "agoal submission raw guaranteed zero" "$evidence_dir/agoal-submit-zero-raw.json" \ + "$dws_bin" agoal report submit-detail --template-id "$template_id" --submit-state ON_TIME --query-date "$future_date" --page 1 --page-size 20 --timeout 60 + assert_jq "agoal submission guaranteed zero alignment" ' + (input) as $raw | + .ok == true and .outcome == "success" and .data.count == 0 and .data.totalCount == 0 and + (.data.submissions | type) == "array" and (.data.submissions | length) == 0 and + $raw.success == true and ($raw.content.result | type) == "array" and + ($raw.content.result | length) == 0 and $raw.content.totalCount == 0 and + .data.page == $raw.content.page and .data.pageSize == $raw.content.pageSize + ' "$evidence_dir/agoal-submit-zero-exact.json" "$evidence_dir/agoal-submit-zero-raw.json" + pass "AGOAL +report-submit-detail exact+raw known-nonempty/future guaranteed-zero stable-user/page alignment" + + pass "AGOAL-INVENTORY public=5 dual-layer-pass=5 unavailable=11" +} + +if [[ $selected_product == all || $selected_product == dev ]]; then + run_dev_gate +fi +if [[ $selected_product == all || $selected_product == devapp ]]; then + # Added below after the Agoal proof block so product execution remains + # Dev -> DevApp -> Agoal when `all` is selected. + run_devapp_gate +fi +if [[ $selected_product == all || $selected_product == agoal ]]; then + run_agoal_gate +fi diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 429d32bb..8d8a416b 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -40,6 +40,8 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_devdoc.json", ROOT / "internal" / "shortcut" / "semantic_catalog_hrbrain.json", ROOT / "internal" / "shortcut" / "semantic_catalog_pat.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_devapp.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_agoal.json", ] diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index fbfb3458..af499c13 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -43,13 +43,14 @@ cli_version: ">=1.0.15" | 服务 | shortcut 数 | multi skill | |---|---:|---| +| `agoal` | 5 | `—` | | `aisearch` | 1 | `—` | | `aitable` | 100 | `dingtalk-aitable` | | `attendance` | 8 | `dingtalk-misc` | | `calendar` | 27 | `dingtalk-calendar` | | `chat` | 98 | `dingtalk-chat` | | `contact` | 13 | `dingtalk-contact` | -| `devapp` | 19 | `dingtalk-misc` | +| `devapp` | 25 | `dingtalk-misc` | | `ding` | 1 | `dingtalk-misc` | | `doc` | 45 | `dingtalk-doc` | | `drive` | 28 | `dingtalk-drive` | diff --git a/skills/multi/dingtalk-misc/references/devapp.md b/skills/multi/dingtalk-misc/references/devapp.md index 1f39d076..3d9d5032 100644 --- a/skills/multi/dingtalk-misc/references/devapp.md +++ b/skills/multi/dingtalk-misc/references/devapp.md @@ -25,19 +25,25 @@ | Shortcut | 风险 | 适用场景 | |---|---|---| | `dws devapp +create` | write | 创建开放平台企业内部应用 | +| `dws devapp +credentials-get` | read | 读取开放平台应用凭证 | | `dws devapp +delete` | high-risk-write | 删除开放平台企业内部应用(不可逆) | | `dws devapp +disable` | high-risk-write | 停用开放平台企业内部应用 | | `dws devapp +enable` | write | 启用开放平台企业内部应用 | -| `dws devapp +event-list` | read | 查询应用已订阅的事件列表 | +| `dws devapp +event-list` | read | 查询应用可用事件目录与订阅状态 | +| `dws devapp +event-subscribe` | write | 订阅应用事件回调 | | `dws devapp +get` | read | 查询开放平台企业内部应用详情 | | `dws devapp +list` | read | 查询开放平台企业内部应用列表 | | `dws devapp +member-add` | write | 添加开放平台应用成员 | | `dws devapp +member-list` | read | 查询开放平台应用成员 | | `dws devapp +member-remove` | high-risk-write | 移除开放平台应用成员 | | `dws devapp +permission-list` | read | 查询开放平台应用权限列表 | +| `dws devapp +robot-config` | write | 创建或更新现有应用的机器人配置(upsert) | +| `dws devapp +robot-disable` | high-risk-write | 停用现有应用的机器人能力 | +| `dws devapp +robot-enable` | write | 启用现有应用机器人能力(纯启用,无需配置字段) | | `dws devapp +robot-get` | read | 查询现有应用的机器人配置 | | `dws devapp +update` | write | 修改开放平台企业内部应用基础信息 | | `dws devapp +version-check-approval` | read | 预检版本发布是否需要审批(不实际发布) | +| `dws devapp +version-create` | write | 基于当前配置创建应用新版本 | | `dws devapp +version-get` | read | 查询指定版本详情 | | `dws devapp +version-list` | read | 分页查询应用版本列表 | | `dws devapp +version-status` | read | 查询版本发布/审批状态 | From e3124ccea11e6267d67febd6956733c49dda1bed Mon Sep 17 00:00:00 2001 From: chichuan Date: Tue, 25 Aug 2026 11:11:21 +0800 Subject: [PATCH 30/33] fix(ci): accept strict ruleset read projection --- .github/workflows/ci.yml | 17 ++- .github/workflows/reviewer-router.yml | 34 +++++- docs/automation.md | 5 +- docs/ci-pr-gates.md | 5 +- ...reviewer_router_ruleset_projection_test.go | 104 ++++++++++++++++++ test/scripts/reviewer_router_workflow_test.go | 24 +++- 6 files changed, 175 insertions(+), 14 deletions(-) create mode 100644 test/scripts/reviewer_router_ruleset_projection_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54a0383d..86ea48b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1013,13 +1013,26 @@ jobs: const writerRuleset = writerRulesets[0]; const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; + // GitHub's read projection omits parameters when this update-rule + // exception is disabled. Accept only that omission or exact false; + // reject a present null/non-boolean field instead of weakening the gate. + function isStrictUpdateRule(rule) { + if (rule?.type !== 'update') { + return false; + } + const updateAllowsFetchAndMerge = + rule.parameters?.update_allows_fetch_and_merge; + return ( + updateAllowsFetchAndMerge === undefined || + updateAllowsFetchAndMerge === false + ); + } if ( writerIncludes.length !== 1 || writerIncludes[0] !== 'refs/heads/main' || writerExcludes.length !== 0 || writerRuleset.rules?.length !== 1 || - writerRuleset.rules[0].type !== 'update' || - writerRuleset.rules[0].parameters?.update_allows_fetch_and_merge !== false || + !isStrictUpdateRule(writerRuleset.rules[0]) || writerRuleset.current_user_can_bypass !== 'never' ) { core.setFailed( diff --git a/.github/workflows/reviewer-router.yml b/.github/workflows/reviewer-router.yml index 7e33568a..68df6c71 100644 --- a/.github/workflows/reviewer-router.yml +++ b/.github/workflows/reviewer-router.yml @@ -446,6 +446,20 @@ jobs: const writerRuleset = writerRulesets[0]; const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; + // GitHub's read projection omits parameters when this update-rule + // exception is disabled. Accept only that omission or exact false; + // reject a present null/non-boolean field instead of weakening the gate. + function isStrictUpdateRule(rule) { + if (rule?.type !== 'update') { + return false; + } + const updateAllowsFetchAndMerge = + rule.parameters?.update_allows_fetch_and_merge; + return ( + updateAllowsFetchAndMerge === undefined || + updateAllowsFetchAndMerge === false + ); + } if ( writerRuleset.source_type !== 'Repository' || writerRuleset.source?.toLowerCase() !== repositorySource || @@ -453,8 +467,7 @@ jobs: writerIncludes[0] !== 'refs/heads/main' || writerExcludes.length !== 0 || writerRuleset.rules?.length !== 1 || - writerRuleset.rules[0].type !== 'update' || - writerRuleset.rules[0].parameters?.update_allows_fetch_and_merge !== false || + !isStrictUpdateRule(writerRuleset.rules[0]) || writerRuleset.current_user_can_bypass !== 'pull_requests_only' ) { throw new Error( @@ -723,6 +736,20 @@ jobs: const writerRuleset = writerRulesets[0]; const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; + // GitHub's read projection omits parameters when this update-rule + // exception is disabled. Accept only that omission or exact false; + // reject a present null/non-boolean field instead of weakening the gate. + function isStrictUpdateRule(rule) { + if (rule?.type !== 'update') { + return false; + } + const updateAllowsFetchAndMerge = + rule.parameters?.update_allows_fetch_and_merge; + return ( + updateAllowsFetchAndMerge === undefined || + updateAllowsFetchAndMerge === false + ); + } if ( writerRuleset.source_type !== 'Repository' || writerRuleset.source?.toLowerCase() !== repositorySource || @@ -730,8 +757,7 @@ jobs: writerIncludes[0] !== 'refs/heads/main' || writerExcludes.length !== 0 || writerRuleset.rules?.length !== 1 || - writerRuleset.rules[0].type !== 'update' || - writerRuleset.rules[0].parameters?.update_allows_fetch_and_merge !== false || + !isStrictUpdateRule(writerRuleset.rules[0]) || writerRuleset.current_user_can_bypass !== 'pull_requests_only' ) { throw new Error( diff --git a/docs/automation.md b/docs/automation.md index 666d2a07..e10868f8 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -82,7 +82,10 @@ it: - set repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY` to its private key; - create one active repository branch ruleset named `main-merge-writers`, targeting only `refs/heads/main`, with exactly one `Restrict updates` rule - (`update_allows_fetch_and_merge: false`); + (`update_allows_fetch_and_merge: false`). GitHub may project that strict + value through the read APIs as `{type: "update"}` with `parameters` omitted; + consumers accept only that omission or an explicit boolean `false`, and + reject every present non-false value; - give that ruleset exactly two bypass actors: the Reviewer Router App as an `Integration` in `pull_request` mode, and `PeterGuy326` (ID `47820304`) in `always` mode for Formula publication and break-glass recovery; diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index c8387c6c..3a998255 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -170,7 +170,10 @@ silently skip the merge commit's protected-main CI and baseline-cache producer. Token minting or takeover fails closed without falling back to `GITHUB_TOKEN`: the unsafe request is cleared before credentials are read, and the required `Test` context live-verifies the exact `main-merge-writers` update -rule and requires its own built-in token to report +rule. GitHub's read APIs may omit `parameters` for the strict +`update_allows_fetch_and_merge: false` value, so the gate accepts only that +omission or an explicit boolean `false` and rejects every present non-false +value. It also requires its own built-in token to report `current_user_can_bypass: never`. The minted App separately requires `pull_requests_only` on that writer rule and `never` on every other active main ruleset before it can enable or reconcile auto-merge. GitHub hides the complete diff --git a/test/scripts/reviewer_router_ruleset_projection_test.go b/test/scripts/reviewer_router_ruleset_projection_test.go new file mode 100644 index 00000000..84aad8b1 --- /dev/null +++ b/test/scripts/reviewer_router_ruleset_projection_test.go @@ -0,0 +1,104 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const strictUpdateRuleHelper = `function isStrictUpdateRule(rule) { + if (rule?.type !== 'update') { + return false; + } + const updateAllowsFetchAndMerge = + rule.parameters?.update_allows_fetch_and_merge; + return ( + updateAllowsFetchAndMerge === undefined || + updateAllowsFetchAndMerge === false + ); +}` + +func TestReviewerRouterAcceptsGitHubStrictUpdateReadProjection(t *testing.T) { + t.Parallel() + + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required to verify the ruleset projection helper") + } + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + + var scripts []string + for _, relativePath := range []string{ + filepath.Join(".github", "workflows", "ci.yml"), + filepath.Join(".github", "workflows", "reviewer-router.yml"), + } { + path := filepath.Join(root, relativePath) + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("read %s: %v", path, readErr) + } + var workflow any + if unmarshalErr := yaml.Unmarshal(data, &workflow); unmarshalErr != nil { + t.Fatalf("parse %s: %v", path, unmarshalErr) + } + collectGitHubScripts(workflow, &scripts) + } + + checked := 0 + for _, script := range scripts { + if !strings.Contains(script, "const writerRuleset = writerRulesets[0];") { + continue + } + checked++ + if got := strings.Count(script, strictUpdateRuleHelper); got != 1 { + t.Errorf("writer-ruleset script contains strict update helper %d times, want 1", got) + } + if strings.Contains( + script, + "parameters?.update_allows_fetch_and_merge !== false", + ) { + t.Error("writer-ruleset script rejects GitHub's omitted strict-false read projection") + } + if !strings.Contains(script, "!isStrictUpdateRule(writerRuleset.rules[0])") { + t.Error("writer-ruleset script does not enforce the strict update helper") + } + } + if checked != 3 { + t.Fatalf("writer-ruleset scripts checked = %d, want App enable, reconcile, and CI self-check", checked) + } + + verification := strictUpdateRuleHelper + ` +const cases = [ + ['omitted parameters', {type: 'update'}, true], + ['null parameters', {type: 'update', parameters: null}, true], + ['empty parameters', {type: 'update', parameters: {}}, true], + ['explicit false', {type: 'update', parameters: {update_allows_fetch_and_merge: false}}, true], + ['explicit true', {type: 'update', parameters: {update_allows_fetch_and_merge: true}}, false], + ['null field', {type: 'update', parameters: {update_allows_fetch_and_merge: null}}, false], + ['string false', {type: 'update', parameters: {update_allows_fetch_and_merge: 'false'}}, false], + ['numeric false', {type: 'update', parameters: {update_allows_fetch_and_merge: 0}}, false], + ['wrong rule type', {type: 'creation'}, false], + ['null rule', null, false], +]; +for (const [name, rule, want] of cases) { + const got = isStrictUpdateRule(rule); + if (got !== want) { + throw new Error(name + ': got ' + got + ', want ' + want); + } +} +` + command := exec.Command(node, "-e", verification) + if output, runErr := command.CombinedOutput(); runErr != nil { + t.Fatalf("strict update projection verification failed: %v\n%s", runErr, output) + } +} diff --git a/test/scripts/reviewer_router_workflow_test.go b/test/scripts/reviewer_router_workflow_test.go index 4c9e5419..ba972fa2 100644 --- a/test/scripts/reviewer_router_workflow_test.go +++ b/test/scripts/reviewer_router_workflow_test.go @@ -294,8 +294,12 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { "writerIncludes[0] !== 'refs/heads/main'", "writerExcludes.length !== 0", "writerRuleset.rules?.length !== 1", - "writerRuleset.rules[0].type !== 'update'", - "update_allows_fetch_and_merge !== false", + "function isStrictUpdateRule(rule)", + "rule?.type !== 'update'", + "rule.parameters?.update_allows_fetch_and_merge", + "updateAllowsFetchAndMerge === undefined", + "updateAllowsFetchAndMerge === false", + "!isStrictUpdateRule(writerRuleset.rules[0])", "current_user_can_bypass !== 'pull_requests_only'", "ruleset.current_user_can_bypass !== 'never'", "Reviewer Router App must never bypass main ruleset", @@ -443,8 +447,12 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { "writerIncludes[0] !== 'refs/heads/main'", "writerExcludes.length !== 0", "writerRuleset.rules?.length !== 1", - "writerRuleset.rules[0].type !== 'update'", - "update_allows_fetch_and_merge !== false", + "function isStrictUpdateRule(rule)", + "rule?.type !== 'update'", + "rule.parameters?.update_allows_fetch_and_merge", + "updateAllowsFetchAndMerge === undefined", + "updateAllowsFetchAndMerge === false", + "!isStrictUpdateRule(writerRuleset.rules[0])", "current_user_can_bypass !== 'pull_requests_only'", "ruleset.current_user_can_bypass !== 'never'", "Reviewer Router App must never bypass main ruleset", @@ -550,8 +558,12 @@ func TestCodeAdmissionEnforcesReviewerRouterWriterBoundary(t *testing.T) { "writerIncludes[0] !== 'refs/heads/main'", "writerExcludes.length !== 0", "writerRuleset.rules?.length !== 1", - "writerRuleset.rules[0].type !== 'update'", - "update_allows_fetch_and_merge !== false", + "function isStrictUpdateRule(rule)", + "rule?.type !== 'update'", + "rule.parameters?.update_allows_fetch_and_merge", + "updateAllowsFetchAndMerge === undefined", + "updateAllowsFetchAndMerge === false", + "!isStrictUpdateRule(writerRuleset.rules[0])", "writerRuleset.current_user_can_bypass !== 'never'", "deny this built-in Actions identity any bypass", "const skipWorkflowPattern =", From aeb1b2ced20f0af1e604daee31636355d72fed10 Mon Sep 17 00:00:00 2001 From: chichuan Date: Tue, 25 Aug 2026 11:39:33 +0800 Subject: [PATCH 31/33] fix(ci): cross-check strict ruleset via GraphQL --- .github/workflows/ci.yml | 77 ++++++++- .github/workflows/reviewer-router.yml | 152 ++++++++++++++++-- docs/automation.md | 6 +- docs/ci-pr-gates.md | 7 +- ...reviewer_router_ruleset_projection_test.go | 98 ++++++++++- test/scripts/reviewer_router_workflow_test.go | 54 +++++-- 6 files changed, 353 insertions(+), 41 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86ea48b4..e8c8c124 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1013,24 +1013,56 @@ jobs: const writerRuleset = writerRulesets[0]; const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; - // GitHub's read projection omits parameters when this update-rule - // exception is disabled. Accept only that omission or exact false; - // reject a present null/non-boolean field instead of weakening the gate. + // GitHub's read projection omits the entire parameters property + // when this exception is disabled. Accept only that exact omission + // or a one-field object containing exact false. function isStrictUpdateRule(rule) { if (rule?.type !== 'update') { return false; } - const updateAllowsFetchAndMerge = - rule.parameters?.update_allows_fetch_and_merge; + if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) { + return true; + } + const parameters = rule.parameters; + if ( + parameters === null || + typeof parameters !== 'object' || + Array.isArray(parameters) + ) { + return false; + } + const parameterKeys = Object.keys(parameters); return ( - updateAllowsFetchAndMerge === undefined || - updateAllowsFetchAndMerge === false + parameterKeys.length === 1 && + parameterKeys[0] === 'update_allows_fetch_and_merge' && + parameters.update_allows_fetch_and_merge === false + ); + } + function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) { + const restRulesetID = Number(restRuleset?.id); + const graphRulesetID = Number(graphRuleset?.databaseId); + const graphRules = graphRuleset?.rules; + const graphRule = graphRules?.nodes?.[0]; + return ( + Number.isSafeInteger(restRulesetID) && + restRulesetID > 0 && + graphRulesetID === restRulesetID && + graphRuleset.name === restRuleset.name && + graphRuleset.enforcement === 'ACTIVE' && + graphRuleset.target === 'BRANCH' && + graphRules?.totalCount === 1 && + graphRules.nodes?.length === 1 && + graphRule?.type === 'UPDATE' && + graphRule.parameters?.__typename === 'UpdateParameters' && + graphRule.parameters.updateAllowsFetchAndMerge === false ); } if ( writerIncludes.length !== 1 || writerIncludes[0] !== 'refs/heads/main' || writerExcludes.length !== 0 || + typeof writerRuleset.node_id !== 'string' || + !writerRuleset.node_id || writerRuleset.rules?.length !== 1 || !isStrictUpdateRule(writerRuleset.rules[0]) || writerRuleset.current_user_can_bypass !== 'never' @@ -1040,6 +1072,37 @@ jobs: ); return; } + const {node: graphWriterRuleset} = await github.graphql( + `query ReviewerRouterWriterRule($rulesetID: ID!) { + node(id: $rulesetID) { + ... on RepositoryRuleset { + databaseId + name + enforcement + target + rules(first: 2) { + totalCount + nodes { + type + parameters { + __typename + ... on UpdateParameters { + updateAllowsFetchAndMerge + } + } + } + } + } + } + }`, + {rulesetID: writerRuleset.node_id}, + ); + if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) { + core.setFailed( + `${writerRulesetName} must expose one strict UPDATE rule with updateAllowsFetchAndMerge=false through GraphQL.`, + ); + return; + } const maxAttempts = 6; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { diff --git a/.github/workflows/reviewer-router.yml b/.github/workflows/reviewer-router.yml index 68df6c71..b4066afd 100644 --- a/.github/workflows/reviewer-router.yml +++ b/.github/workflows/reviewer-router.yml @@ -446,18 +446,48 @@ jobs: const writerRuleset = writerRulesets[0]; const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; - // GitHub's read projection omits parameters when this update-rule - // exception is disabled. Accept only that omission or exact false; - // reject a present null/non-boolean field instead of weakening the gate. + // GitHub's read projection omits the entire parameters property + // when this exception is disabled. Accept only that exact omission + // or a one-field object containing exact false. function isStrictUpdateRule(rule) { if (rule?.type !== 'update') { return false; } - const updateAllowsFetchAndMerge = - rule.parameters?.update_allows_fetch_and_merge; + if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) { + return true; + } + const parameters = rule.parameters; + if ( + parameters === null || + typeof parameters !== 'object' || + Array.isArray(parameters) + ) { + return false; + } + const parameterKeys = Object.keys(parameters); return ( - updateAllowsFetchAndMerge === undefined || - updateAllowsFetchAndMerge === false + parameterKeys.length === 1 && + parameterKeys[0] === 'update_allows_fetch_and_merge' && + parameters.update_allows_fetch_and_merge === false + ); + } + function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) { + const restRulesetID = Number(restRuleset?.id); + const graphRulesetID = Number(graphRuleset?.databaseId); + const graphRules = graphRuleset?.rules; + const graphRule = graphRules?.nodes?.[0]; + return ( + Number.isSafeInteger(restRulesetID) && + restRulesetID > 0 && + graphRulesetID === restRulesetID && + graphRuleset.name === restRuleset.name && + graphRuleset.enforcement === 'ACTIVE' && + graphRuleset.target === 'BRANCH' && + graphRules?.totalCount === 1 && + graphRules.nodes?.length === 1 && + graphRule?.type === 'UPDATE' && + graphRule.parameters?.__typename === 'UpdateParameters' && + graphRule.parameters.updateAllowsFetchAndMerge === false ); } if ( @@ -466,6 +496,8 @@ jobs: writerIncludes.length !== 1 || writerIncludes[0] !== 'refs/heads/main' || writerExcludes.length !== 0 || + typeof writerRuleset.node_id !== 'string' || + !writerRuleset.node_id || writerRuleset.rules?.length !== 1 || !isStrictUpdateRule(writerRuleset.rules[0]) || writerRuleset.current_user_can_bypass !== 'pull_requests_only' @@ -474,6 +506,36 @@ jobs: 'Reviewer Router App requires the exact repository-owned main writer rule with pull-request-only bypass.', ); } + const {node: graphWriterRuleset} = await github.graphql( + `query ReviewerRouterWriterRule($rulesetID: ID!) { + node(id: $rulesetID) { + ... on RepositoryRuleset { + databaseId + name + enforcement + target + rules(first: 2) { + totalCount + nodes { + type + parameters { + __typename + ... on UpdateParameters { + updateAllowsFetchAndMerge + } + } + } + } + } + } + }`, + {rulesetID: writerRuleset.node_id}, + ); + if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) { + throw new Error( + 'Reviewer Router App requires GraphQL to expose one strict UPDATE rule with updateAllowsFetchAndMerge=false.', + ); + } for (const ruleset of activeMainRulesets) { if (ruleset.id === writerRuleset.id) { continue; @@ -736,18 +798,48 @@ jobs: const writerRuleset = writerRulesets[0]; const writerIncludes = writerRuleset.conditions?.ref_name?.include || []; const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || []; - // GitHub's read projection omits parameters when this update-rule - // exception is disabled. Accept only that omission or exact false; - // reject a present null/non-boolean field instead of weakening the gate. + // GitHub's read projection omits the entire parameters property + // when this exception is disabled. Accept only that exact omission + // or a one-field object containing exact false. function isStrictUpdateRule(rule) { if (rule?.type !== 'update') { return false; } - const updateAllowsFetchAndMerge = - rule.parameters?.update_allows_fetch_and_merge; + if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) { + return true; + } + const parameters = rule.parameters; + if ( + parameters === null || + typeof parameters !== 'object' || + Array.isArray(parameters) + ) { + return false; + } + const parameterKeys = Object.keys(parameters); return ( - updateAllowsFetchAndMerge === undefined || - updateAllowsFetchAndMerge === false + parameterKeys.length === 1 && + parameterKeys[0] === 'update_allows_fetch_and_merge' && + parameters.update_allows_fetch_and_merge === false + ); + } + function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) { + const restRulesetID = Number(restRuleset?.id); + const graphRulesetID = Number(graphRuleset?.databaseId); + const graphRules = graphRuleset?.rules; + const graphRule = graphRules?.nodes?.[0]; + return ( + Number.isSafeInteger(restRulesetID) && + restRulesetID > 0 && + graphRulesetID === restRulesetID && + graphRuleset.name === restRuleset.name && + graphRuleset.enforcement === 'ACTIVE' && + graphRuleset.target === 'BRANCH' && + graphRules?.totalCount === 1 && + graphRules.nodes?.length === 1 && + graphRule?.type === 'UPDATE' && + graphRule.parameters?.__typename === 'UpdateParameters' && + graphRule.parameters.updateAllowsFetchAndMerge === false ); } if ( @@ -756,6 +848,8 @@ jobs: writerIncludes.length !== 1 || writerIncludes[0] !== 'refs/heads/main' || writerExcludes.length !== 0 || + typeof writerRuleset.node_id !== 'string' || + !writerRuleset.node_id || writerRuleset.rules?.length !== 1 || !isStrictUpdateRule(writerRuleset.rules[0]) || writerRuleset.current_user_can_bypass !== 'pull_requests_only' @@ -764,6 +858,36 @@ jobs: 'Reviewer Router App requires the exact repository-owned main writer rule with pull-request-only bypass.', ); } + const {node: graphWriterRuleset} = await github.graphql( + `query ReviewerRouterWriterRule($rulesetID: ID!) { + node(id: $rulesetID) { + ... on RepositoryRuleset { + databaseId + name + enforcement + target + rules(first: 2) { + totalCount + nodes { + type + parameters { + __typename + ... on UpdateParameters { + updateAllowsFetchAndMerge + } + } + } + } + } + } + }`, + {rulesetID: writerRuleset.node_id}, + ); + if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) { + throw new Error( + 'Reviewer Router App requires GraphQL to expose one strict UPDATE rule with updateAllowsFetchAndMerge=false.', + ); + } for (const ruleset of activeMainRulesets) { if (ruleset.id === writerRuleset.id) { continue; diff --git a/docs/automation.md b/docs/automation.md index e10868f8..a3636bf1 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -84,8 +84,10 @@ it: targeting only `refs/heads/main`, with exactly one `Restrict updates` rule (`update_allows_fetch_and_merge: false`). GitHub may project that strict value through the read APIs as `{type: "update"}` with `parameters` omitted; - consumers accept only that omission or an explicit boolean `false`, and - reject every present non-false value; + consumers accept only that exact omission or a one-field `parameters` object + containing explicit boolean `false`, and reject every other present shape or + value. They then bind the same ruleset node through GraphQL and require its + non-null `updateAllowsFetchAndMerge` value to be exactly `false`; - give that ruleset exactly two bypass actors: the Reviewer Router App as an `Integration` in `pull_request` mode, and `PeterGuy326` (ID `47820304`) in `always` mode for Formula publication and break-glass recovery; diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 3a998255..6254e564 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -172,8 +172,11 @@ producer. Token minting or takeover fails closed without falling back to the required `Test` context live-verifies the exact `main-merge-writers` update rule. GitHub's read APIs may omit `parameters` for the strict `update_allows_fetch_and_merge: false` value, so the gate accepts only that -omission or an explicit boolean `false` and rejects every present non-false -value. It also requires its own built-in token to report +exact omission or a one-field `parameters` object containing explicit boolean +`false`; every other present shape or value fails closed. The gate then binds +the same ruleset node through GraphQL and requires its non-null +`updateAllowsFetchAndMerge` value to be exactly `false`. It also requires its +own built-in token to report `current_user_can_bypass: never`. The minted App separately requires `pull_requests_only` on that writer rule and `never` on every other active main ruleset before it can enable or reconcile auto-merge. GitHub hides the complete diff --git a/test/scripts/reviewer_router_ruleset_projection_test.go b/test/scripts/reviewer_router_ruleset_projection_test.go index 84aad8b1..2b9457af 100644 --- a/test/scripts/reviewer_router_ruleset_projection_test.go +++ b/test/scripts/reviewer_router_ruleset_projection_test.go @@ -17,11 +17,42 @@ const strictUpdateRuleHelper = `function isStrictUpdateRule(rule) { if (rule?.type !== 'update') { return false; } - const updateAllowsFetchAndMerge = - rule.parameters?.update_allows_fetch_and_merge; + if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) { + return true; + } + const parameters = rule.parameters; + if ( + parameters === null || + typeof parameters !== 'object' || + Array.isArray(parameters) + ) { + return false; + } + const parameterKeys = Object.keys(parameters); return ( - updateAllowsFetchAndMerge === undefined || - updateAllowsFetchAndMerge === false + parameterKeys.length === 1 && + parameterKeys[0] === 'update_allows_fetch_and_merge' && + parameters.update_allows_fetch_and_merge === false + ); +}` + +const strictGraphQLUpdateRuleHelper = `function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) { + const restRulesetID = Number(restRuleset?.id); + const graphRulesetID = Number(graphRuleset?.databaseId); + const graphRules = graphRuleset?.rules; + const graphRule = graphRules?.nodes?.[0]; + return ( + Number.isSafeInteger(restRulesetID) && + restRulesetID > 0 && + graphRulesetID === restRulesetID && + graphRuleset.name === restRuleset.name && + graphRuleset.enforcement === 'ACTIVE' && + graphRuleset.target === 'BRANCH' && + graphRules?.totalCount === 1 && + graphRules.nodes?.length === 1 && + graphRule?.type === 'UPDATE' && + graphRule.parameters?.__typename === 'UpdateParameters' && + graphRule.parameters.updateAllowsFetchAndMerge === false ); }` @@ -63,6 +94,9 @@ func TestReviewerRouterAcceptsGitHubStrictUpdateReadProjection(t *testing.T) { if got := strings.Count(script, strictUpdateRuleHelper); got != 1 { t.Errorf("writer-ruleset script contains strict update helper %d times, want 1", got) } + if got := strings.Count(script, strictGraphQLUpdateRuleHelper); got != 1 { + t.Errorf("writer-ruleset script contains GraphQL strict update helper %d times, want 1", got) + } if strings.Contains( script, "parameters?.update_allows_fetch_and_merge !== false", @@ -72,21 +106,36 @@ func TestReviewerRouterAcceptsGitHubStrictUpdateReadProjection(t *testing.T) { if !strings.Contains(script, "!isStrictUpdateRule(writerRuleset.rules[0])") { t.Error("writer-ruleset script does not enforce the strict update helper") } + for _, marker := range []string{ + "query ReviewerRouterWriterRule($rulesetID: ID!)", + "rules(first: 2)", + "updateAllowsFetchAndMerge", + "{rulesetID: writerRuleset.node_id}", + "!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)", + } { + if !strings.Contains(script, marker) { + t.Errorf("writer-ruleset script does not enforce GraphQL marker %q", marker) + } + } } if checked != 3 { t.Fatalf("writer-ruleset scripts checked = %d, want App enable, reconcile, and CI self-check", checked) } - verification := strictUpdateRuleHelper + ` + verification := strictUpdateRuleHelper + "\n" + strictGraphQLUpdateRuleHelper + ` const cases = [ ['omitted parameters', {type: 'update'}, true], - ['null parameters', {type: 'update', parameters: null}, true], - ['empty parameters', {type: 'update', parameters: {}}, true], ['explicit false', {type: 'update', parameters: {update_allows_fetch_and_merge: false}}, true], + ['null parameters', {type: 'update', parameters: null}, false], + ['empty parameters', {type: 'update', parameters: {}}, false], + ['boolean parameters', {type: 'update', parameters: false}, false], + ['string parameters', {type: 'update', parameters: 'malformed'}, false], + ['array parameters', {type: 'update', parameters: []}, false], ['explicit true', {type: 'update', parameters: {update_allows_fetch_and_merge: true}}, false], ['null field', {type: 'update', parameters: {update_allows_fetch_and_merge: null}}, false], ['string false', {type: 'update', parameters: {update_allows_fetch_and_merge: 'false'}}, false], ['numeric false', {type: 'update', parameters: {update_allows_fetch_and_merge: 0}}, false], + ['extra parameter', {type: 'update', parameters: {update_allows_fetch_and_merge: false, future_exception: false}}, false], ['wrong rule type', {type: 'creation'}, false], ['null rule', null, false], ]; @@ -96,6 +145,41 @@ for (const [name, rule, want] of cases) { throw new Error(name + ': got ' + got + ', want ' + want); } } + +const restRuleset = {id: 21363804, name: 'main-merge-writers'}; +const graphRule = { + type: 'UPDATE', + parameters: { + __typename: 'UpdateParameters', + updateAllowsFetchAndMerge: false, + }, +}; +const graphRuleset = { + databaseId: 21363804, + name: 'main-merge-writers', + enforcement: 'ACTIVE', + target: 'BRANCH', + rules: {totalCount: 1, nodes: [graphRule]}, +}; +const graphCases = [ + ['valid GraphQL projection', restRuleset, graphRuleset, true], + ['invalid REST id', {id: 0, name: 'main-merge-writers'}, graphRuleset, false], + ['mismatched GraphQL id', restRuleset, {...graphRuleset, databaseId: 7}, false], + ['mismatched name', restRuleset, {...graphRuleset, name: 'other'}, false], + ['inactive ruleset', restRuleset, {...graphRuleset, enforcement: 'EVALUATE'}, false], + ['wrong target', restRuleset, {...graphRuleset, target: 'TAG'}, false], + ['extra rule', restRuleset, {...graphRuleset, rules: {totalCount: 2, nodes: [graphRule, graphRule]}}, false], + ['wrong rule type', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, type: 'CREATION'}]}}, false], + ['wrong parameter type', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, parameters: {...graphRule.parameters, __typename: 'PullRequestParameters'}}]}}, false], + ['fetch and merge enabled', restRuleset, {...graphRuleset, rules: {totalCount: 1, nodes: [{...graphRule, parameters: {...graphRule.parameters, updateAllowsFetchAndMerge: true}}]}}, false], + ['missing GraphQL node', restRuleset, null, false], +]; +for (const [name, rest, graph, want] of graphCases) { + const got = isStrictGraphQLUpdateRule(rest, graph); + if (got !== want) { + throw new Error(name + ': got ' + got + ', want ' + want); + } +} ` command := exec.Command(node, "-e", verification) if output, runErr := command.CombinedOutput(); runErr != nil { diff --git a/test/scripts/reviewer_router_workflow_test.go b/test/scripts/reviewer_router_workflow_test.go index ba972fa2..0d947707 100644 --- a/test/scripts/reviewer_router_workflow_test.go +++ b/test/scripts/reviewer_router_workflow_test.go @@ -296,10 +296,22 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { "writerRuleset.rules?.length !== 1", "function isStrictUpdateRule(rule)", "rule?.type !== 'update'", - "rule.parameters?.update_allows_fetch_and_merge", - "updateAllowsFetchAndMerge === undefined", - "updateAllowsFetchAndMerge === false", + "!Object.prototype.hasOwnProperty.call(rule, 'parameters')", + "parameters === null", + "typeof parameters !== 'object'", + "Array.isArray(parameters)", + "const parameterKeys = Object.keys(parameters)", + "parameterKeys.length === 1", + "parameterKeys[0] === 'update_allows_fetch_and_merge'", + "parameters.update_allows_fetch_and_merge === false", "!isStrictUpdateRule(writerRuleset.rules[0])", + "typeof writerRuleset.node_id !== 'string'", + "function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)", + "graphRule.parameters.updateAllowsFetchAndMerge === false", + "query ReviewerRouterWriterRule($rulesetID: ID!)", + "... on UpdateParameters", + "{rulesetID: writerRuleset.node_id}", + "!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)", "current_user_can_bypass !== 'pull_requests_only'", "ruleset.current_user_can_bypass !== 'never'", "Reviewer Router App must never bypass main ruleset", @@ -449,10 +461,22 @@ func TestReviewerRouterWorkflowContract(t *testing.T) { "writerRuleset.rules?.length !== 1", "function isStrictUpdateRule(rule)", "rule?.type !== 'update'", - "rule.parameters?.update_allows_fetch_and_merge", - "updateAllowsFetchAndMerge === undefined", - "updateAllowsFetchAndMerge === false", + "!Object.prototype.hasOwnProperty.call(rule, 'parameters')", + "parameters === null", + "typeof parameters !== 'object'", + "Array.isArray(parameters)", + "const parameterKeys = Object.keys(parameters)", + "parameterKeys.length === 1", + "parameterKeys[0] === 'update_allows_fetch_and_merge'", + "parameters.update_allows_fetch_and_merge === false", "!isStrictUpdateRule(writerRuleset.rules[0])", + "typeof writerRuleset.node_id !== 'string'", + "function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)", + "graphRule.parameters.updateAllowsFetchAndMerge === false", + "query ReviewerRouterWriterRule($rulesetID: ID!)", + "... on UpdateParameters", + "{rulesetID: writerRuleset.node_id}", + "!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)", "current_user_can_bypass !== 'pull_requests_only'", "ruleset.current_user_can_bypass !== 'never'", "Reviewer Router App must never bypass main ruleset", @@ -560,10 +584,22 @@ func TestCodeAdmissionEnforcesReviewerRouterWriterBoundary(t *testing.T) { "writerRuleset.rules?.length !== 1", "function isStrictUpdateRule(rule)", "rule?.type !== 'update'", - "rule.parameters?.update_allows_fetch_and_merge", - "updateAllowsFetchAndMerge === undefined", - "updateAllowsFetchAndMerge === false", + "!Object.prototype.hasOwnProperty.call(rule, 'parameters')", + "parameters === null", + "typeof parameters !== 'object'", + "Array.isArray(parameters)", + "const parameterKeys = Object.keys(parameters)", + "parameterKeys.length === 1", + "parameterKeys[0] === 'update_allows_fetch_and_merge'", + "parameters.update_allows_fetch_and_merge === false", "!isStrictUpdateRule(writerRuleset.rules[0])", + "typeof writerRuleset.node_id !== 'string'", + "function isStrictGraphQLUpdateRule(restRuleset, graphRuleset)", + "graphRule.parameters.updateAllowsFetchAndMerge === false", + "query ReviewerRouterWriterRule($rulesetID: ID!)", + "... on UpdateParameters", + "{rulesetID: writerRuleset.node_id}", + "!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)", "writerRuleset.current_user_can_bypass !== 'never'", "deny this built-in Actions identity any bypass", "const skipWorkflowPattern =", From c629e1e3eb59b4db83bb837e0f5ce9a27f315de9 Mon Sep 17 00:00:00 2001 From: chichuan Date: Tue, 25 Aug 2026 12:34:32 +0800 Subject: [PATCH 32/33] fix(ci): handle read-only merge defaults projection GitHub omits merge-related repository settings from tokens without Contents write. Accept only the exact dual omission in read-only admission, and require the dedicated App to observe the reviewed values before any auto-merge mutation. --- .github/workflows/ci.yml | 43 ++++- .github/workflows/reviewer-router.yml | 74 ++++++++ docs/automation.md | 7 +- docs/ci-pr-gates.md | 7 +- ...r_router_merge_defaults_projection_test.go | 173 ++++++++++++++++++ test/scripts/reviewer_router_workflow_test.go | 6 +- 6 files changed, 301 insertions(+), 9 deletions(-) create mode 100644 test/scripts/reviewer_router_merge_defaults_projection_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8c8c124..31c4a2ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -954,15 +954,48 @@ jobs: const skipWorkflowPattern = /\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i; const {data: repository} = await github.rest.repos.get({owner, repo}); - if ( - repository.merge_commit_title !== 'MERGE_MESSAGE' || - !['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message) - ) { + function classifyMergeDefaults(repository) { + if ( + repository === null || + typeof repository !== 'object' || + Array.isArray(repository) + ) { + return 'invalid'; + } + const hasTitle = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_title', + ); + const hasMessage = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_message', + ); + if (!hasTitle && !hasMessage) { + return 'omitted'; + } + if (!hasTitle || !hasMessage) { + return 'invalid'; + } + if ( + repository.merge_commit_title === 'MERGE_MESSAGE' && + ['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message) + ) { + return 'reviewed'; + } + return 'invalid'; + } + const mergeDefaultsProjection = classifyMergeDefaults(repository); + if (mergeDefaultsProjection === 'invalid') { core.setFailed( - 'Repository merge-message defaults changed from their reviewed values.', + 'Repository merge-message defaults are malformed or changed from their reviewed values.', ); return; } + if (mergeDefaultsProjection === 'omitted') { + core.info( + 'Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.', + ); + } const appliedRules = await github.paginate( 'GET /repos/{owner}/{repo}/rules/branches/{branch}', diff --git a/.github/workflows/reviewer-router.yml b/.github/workflows/reviewer-router.yml index b4066afd..0b7988f5 100644 --- a/.github/workflows/reviewer-router.yml +++ b/.github/workflows/reviewer-router.yml @@ -405,6 +405,43 @@ jobs: ); } const expectedAppOwner = `${appSlug}[bot]`; + const {data: repository} = await github.rest.repos.get({owner, repo}); + function classifyMergeDefaults(repository) { + if ( + repository === null || + typeof repository !== 'object' || + Array.isArray(repository) + ) { + return 'invalid'; + } + const hasTitle = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_title', + ); + const hasMessage = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_message', + ); + if (!hasTitle && !hasMessage) { + return 'omitted'; + } + if (!hasTitle || !hasMessage) { + return 'invalid'; + } + if ( + repository.merge_commit_title === 'MERGE_MESSAGE' && + ['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message) + ) { + return 'reviewed'; + } + return 'invalid'; + } + const mergeDefaultsProjection = classifyMergeDefaults(repository); + if (mergeDefaultsProjection !== 'reviewed') { + throw new Error( + 'Dedicated Reviewer Router App cannot verify the reviewed repository merge-message defaults.', + ); + } const repositorySource = `${owner}/${repo}`.toLowerCase(); const appliedRules = await github.paginate( 'GET /repos/{owner}/{repo}/rules/branches/{branch}', @@ -757,6 +794,43 @@ jobs: ); } const expectedAppOwner = `${appSlug}[bot]`; + const {data: repository} = await github.rest.repos.get({owner, repo}); + function classifyMergeDefaults(repository) { + if ( + repository === null || + typeof repository !== 'object' || + Array.isArray(repository) + ) { + return 'invalid'; + } + const hasTitle = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_title', + ); + const hasMessage = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_message', + ); + if (!hasTitle && !hasMessage) { + return 'omitted'; + } + if (!hasTitle || !hasMessage) { + return 'invalid'; + } + if ( + repository.merge_commit_title === 'MERGE_MESSAGE' && + ['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message) + ) { + return 'reviewed'; + } + return 'invalid'; + } + const mergeDefaultsProjection = classifyMergeDefaults(repository); + if (mergeDefaultsProjection !== 'reviewed') { + throw new Error( + 'Dedicated Reviewer Router App cannot verify the reviewed repository merge-message defaults.', + ); + } const repositorySource = `${owner}/${repo}`.toLowerCase(); const appliedRules = await github.paginate( 'GET /repos/{owner}/{repo}/rules/branches/{branch}', diff --git a/docs/automation.md b/docs/automation.md index a3636bf1..e2efc038 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -130,7 +130,12 @@ silently re-enabling the request, leaving it available only to the designated break-glass identity. The required `Test` context rejects GitHub workflow-skip directives in the PR title or an existing auto-merge request and verifies the repository's reviewed `MERGE_MESSAGE` title plus `PR_TITLE` or `BLANK` body -defaults. The dedicated App binds the mutation to the exact head OID and +defaults. GitHub does not expose those merge-related settings to the read-only +admission token: the classifier accepts only both exact reviewed values or the +complete omission of both properties, and rejects partial omission, `null`, or +any other value. Before any enable or reconcile mutation, the dedicated App's +current-repository token (which has `Contents: write`) must observe both exact +reviewed values. The dedicated App binds the mutation to the exact head OID and supplies a fixed safe headline and body, so GitHub cannot copy an unsafe PR title into its merge commit. After enabling, the workflow requires the owner to equal the token action's diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 6254e564..1b64ea74 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -179,7 +179,12 @@ the same ruleset node through GraphQL and requires its non-null own built-in token to report `current_user_can_bypass: never`. The minted App separately requires `pull_requests_only` on that writer rule and `never` on every other active main -ruleset before it can enable or reconcile auto-merge. GitHub hides the complete +ruleset before it can enable or reconcile auto-merge. The read-only `Test` +token may receive a repository projection with both merge-default properties +omitted; it accepts only that complete omission or exact `MERGE_MESSAGE` plus +`PR_TITLE`/`BLANK`, while partial or malformed projections fail closed. The +minted App's `Contents: write` token must observe the exact reviewed defaults +before either mutation path proceeds. GitHub hides the complete `bypass_actors` list from low-privilege callers, so the rollout audit must still keep the writer list at exactly the Reviewer App plus `PeterGuy326` (ID `47820304`). The required check finally accepts a null or exact App-owned diff --git a/test/scripts/reviewer_router_merge_defaults_projection_test.go b/test/scripts/reviewer_router_merge_defaults_projection_test.go new file mode 100644 index 00000000..1f201b3b --- /dev/null +++ b/test/scripts/reviewer_router_merge_defaults_projection_test.go @@ -0,0 +1,173 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const mergeDefaultsProjectionHelper = `function classifyMergeDefaults(repository) { + if ( + repository === null || + typeof repository !== 'object' || + Array.isArray(repository) + ) { + return 'invalid'; + } + const hasTitle = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_title', + ); + const hasMessage = Object.prototype.hasOwnProperty.call( + repository, + 'merge_commit_message', + ); + if (!hasTitle && !hasMessage) { + return 'omitted'; + } + if (!hasTitle || !hasMessage) { + return 'invalid'; + } + if ( + repository.merge_commit_title === 'MERGE_MESSAGE' && + ['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message) + ) { + return 'reviewed'; + } + return 'invalid'; +}` + +const privilegedMergeDefaultsGuard = `if (mergeDefaultsProjection !== 'reviewed') { + throw new Error( + 'Dedicated Reviewer Router App cannot verify the reviewed repository merge-message defaults.', + ); +}` + +func TestReviewerRouterMergeDefaultsProjection(t *testing.T) { + t.Parallel() + + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required to verify merge-default projection semantics") + } + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + + var scripts []string + for _, relativePath := range []string{ + filepath.Join(".github", "workflows", "ci.yml"), + filepath.Join(".github", "workflows", "reviewer-router.yml"), + } { + path := filepath.Join(root, relativePath) + data, readErr := os.ReadFile(path) + if readErr != nil { + t.Fatalf("read %s: %v", path, readErr) + } + var workflow any + if unmarshalErr := yaml.Unmarshal(data, &workflow); unmarshalErr != nil { + t.Fatalf("parse %s: %v", path, unmarshalErr) + } + collectGitHubScripts(workflow, &scripts) + } + + checked := 0 + readOnlyChecks := 0 + privilegedChecks := 0 + for _, script := range scripts { + if strings.Count(script, mergeDefaultsProjectionHelper) != 1 { + continue + } + checked++ + for _, marker := range []string{ + "github.rest.repos.get", + "const mergeDefaultsProjection = classifyMergeDefaults(repository);", + } { + if !strings.Contains(script, marker) { + t.Errorf("merge-default script does not enforce marker %q", marker) + } + } + if strings.Contains(script, "MINTED_REVIEWER_ROUTER_APP_SLUG") { + privilegedChecks++ + for _, marker := range []string{ + privilegedMergeDefaultsGuard, + } { + if !strings.Contains(script, marker) { + t.Errorf("privileged merge-default script missing marker %q", marker) + } + } + guardIndex := strings.Index(script, privilegedMergeDefaultsGuard) + guardEndIndex := guardIndex + len(privilegedMergeDefaultsGuard) + firstMutationIndex := len(script) + for _, mutation := range []string{ + "disablePullRequestAutoMerge", + "enablePullRequestAutoMerge", + } { + if index := strings.Index(script, mutation); index >= 0 && index < firstMutationIndex { + firstMutationIndex = index + } + } + if guardIndex < 0 || firstMutationIndex == len(script) || guardEndIndex >= firstMutationIndex { + t.Error("privileged merge-default validation must precede every auto-merge mutation") + } + continue + } + readOnlyChecks++ + for _, marker := range []string{ + "mergeDefaultsProjection === 'invalid'", + "mergeDefaultsProjection === 'omitted'", + "Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.", + } { + if !strings.Contains(script, marker) { + t.Errorf("read-only merge-default script missing marker %q", marker) + } + } + if strings.Contains(script, "mergeDefaultsProjection !== 'reviewed'") { + t.Error("read-only CI must distinguish exact dual omission from malformed projections") + } + } + if checked != 3 || readOnlyChecks != 1 || privilegedChecks != 2 { + t.Fatalf( + "merge-default scripts checked=%d read-only=%d privileged=%d, want 3/1/2", + checked, + readOnlyChecks, + privilegedChecks, + ) + } + + verification := mergeDefaultsProjectionHelper + ` +const cases = [ + ['reviewed PR title', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'PR_TITLE'}, 'reviewed'], + ['reviewed blank body', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'BLANK'}, 'reviewed'], + ['dual omission', {}, 'omitted'], + ['null repository', null, 'invalid'], + ['array repository', [], 'invalid'], + ['title only', {merge_commit_title: 'MERGE_MESSAGE'}, 'invalid'], + ['message only', {merge_commit_message: 'PR_TITLE'}, 'invalid'], + ['own undefined title', {merge_commit_title: undefined, merge_commit_message: 'PR_TITLE'}, 'invalid'], + ['own undefined message', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: undefined}, 'invalid'], + ['null title', {merge_commit_title: null, merge_commit_message: 'PR_TITLE'}, 'invalid'], + ['null message', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: null}, 'invalid'], + ['unsafe title', {merge_commit_title: 'PR_TITLE', merge_commit_message: 'PR_TITLE'}, 'invalid'], + ['unsafe body', {merge_commit_title: 'MERGE_MESSAGE', merge_commit_message: 'PR_BODY'}, 'invalid'], +]; +for (const [name, repository, want] of cases) { + const got = classifyMergeDefaults(repository); + if (got !== want) { + throw new Error(name + ': got ' + got + ', want ' + want); + } +} +` + command := exec.Command(node, "-e", verification) + if output, runErr := command.CombinedOutput(); runErr != nil { + t.Fatalf("merge-default projection verification failed: %v\n%s", runErr, output) + } +} diff --git a/test/scripts/reviewer_router_workflow_test.go b/test/scripts/reviewer_router_workflow_test.go index 0d947707..e33345e5 100644 --- a/test/scripts/reviewer_router_workflow_test.go +++ b/test/scripts/reviewer_router_workflow_test.go @@ -568,8 +568,10 @@ func TestCodeAdmissionEnforcesReviewerRouterWriterBoundary(t *testing.T) { "const expectedAppOwner = `${appSlug}[bot]`", "github.rest.pulls.get", "github.rest.repos.get", - "repository.merge_commit_title !== 'MERGE_MESSAGE'", - "!['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message)", + "function classifyMergeDefaults(repository)", + "mergeDefaultsProjection === 'invalid'", + "mergeDefaultsProjection === 'omitted'", + "exact validation is delegated to the dedicated App", "const writerRulesetName = 'main-merge-writers'", "GET /repos/{owner}/{repo}/rules/branches/{branch}", "rule.ruleset_source_type === 'Repository'", From b45eba0f5c00ff51b036ddc3d1819a486fbc024e Mon Sep 17 00:00:00 2001 From: chichuan Date: Tue, 25 Aug 2026 14:06:09 +0800 Subject: [PATCH 33/33] fix(ci): tolerate merged PR base advancement Bind coverage repair to the stable PR head and merge identity plus protected-main containment without treating the live base SHA projection as permanent identity. Add semantic regression coverage for base advancement and update the governance documentation. --- .../workflows/coverage-baseline-repair.yml | 81 +++++++--- docs/automation.md | 7 +- docs/ci-pr-gates.md | 9 +- .../coverage_baseline_repair_identity_test.go | 145 ++++++++++++++++++ .../coverage_workflow_contract_test.go | 47 ++++-- 5 files changed, 245 insertions(+), 44 deletions(-) create mode 100644 test/scripts/coverage_baseline_repair_identity_test.go diff --git a/.github/workflows/coverage-baseline-repair.yml b/.github/workflows/coverage-baseline-repair.yml index 23190ae5..2d1b30f7 100644 --- a/.github/workflows/coverage-baseline-repair.yml +++ b/.github/workflows/coverage-baseline-repair.yml @@ -53,7 +53,7 @@ jobs: const fullCommit = /^[0-9a-f]{40}$/; const pullNumber = Number(eventPull?.number); const headSha = eventPull?.head?.sha; - const baseSha = eventPull?.base?.sha; + const baseRef = eventPull?.base?.ref; const mergeCommitSha = eventPull?.merge_commit_sha; if ( context.payload.repository?.full_name !== @@ -62,27 +62,44 @@ jobs: !Number.isSafeInteger(pullNumber) || pullNumber <= 0 || !fullCommit.test(headSha || '') || - !fullCommit.test(baseSha || '') || + baseRef !== 'main' || !fullCommit.test(mergeCommitSha || '') ) { throw new Error('closed PR event has an invalid repository or revision identity'); } + // REST base.sha follows the live base branch and can move after + // merge. Bind the closed event's stable PR head snapshot and merge + // facts, then authorize the target through main containment. + function isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + mergeCommitSha, + ) { + return ( + currentPull?.number === pullNumber && + currentPull.state === 'closed' && + currentPull.merged === true && + typeof currentPull.merged_at === 'string' && + currentPull.merged_at.length > 0 && + currentPull.base?.ref === 'main' && + currentPull.head?.sha === headSha && + currentPull.merge_commit_sha === mergeCommitSha + ); + } + const {data: currentPull} = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber, }); - if ( - currentPull.number !== pullNumber || - currentPull.state !== 'closed' || - currentPull.merged !== true || - !currentPull.merged_at || - currentPull.base.ref !== 'main' || - currentPull.base.sha !== baseSha || - currentPull.head.sha !== headSha || - currentPull.merge_commit_sha !== mergeCommitSha - ) { + if (!isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + mergeCommitSha, + )) { throw new Error(`PR #${pullNumber} no longer matches the merged-main event`); } @@ -174,7 +191,6 @@ jobs: source: 'merged_pr', pull_number: String(pullNumber), head_sha: headSha, - base_sha: baseSha, merge_commit_sha: mergeCommitSha, source_run_id: String(context.runId), }, @@ -338,6 +354,27 @@ jobs: ); } + // The dispatcher froze the stable PR head snapshot in this payload. + // Do not re-read mutable base.sha; bind the head and stable merge + // facts, then prove protected-main containment below. + function isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + mergeCommitSha, + ) { + return ( + currentPull?.number === pullNumber && + currentPull.state === 'closed' && + currentPull.merged === true && + typeof currentPull.merged_at === 'string' && + currentPull.merged_at.length > 0 && + currentPull.base?.ref === 'main' && + currentPull.head?.sha === headSha && + currentPull.merge_commit_sha === mergeCommitSha + ); + } + let targetSha; if (context.eventName === 'repository_dispatch') { const payload = context.payload.client_payload || {}; @@ -349,13 +386,11 @@ jobs: const rawPullNumber = String(payload.pull_number || ''); const pullNumber = Number(rawPullNumber); const headSha = payload.head_sha; - const baseSha = payload.base_sha; targetSha = payload.merge_commit_sha; if ( !/^[1-9][0-9]*$/.test(rawPullNumber) || !Number.isSafeInteger(pullNumber) || !fullCommit.test(headSha || '') || - !fullCommit.test(baseSha || '') || !fullCommit.test(targetSha || '') ) { throw new Error('coverage-baseline-repair payload has an invalid PR identity'); @@ -365,16 +400,12 @@ jobs: repo, pull_number: pullNumber, }); - if ( - currentPull.number !== pullNumber || - currentPull.state !== 'closed' || - currentPull.merged !== true || - !currentPull.merged_at || - currentPull.base.ref !== 'main' || - currentPull.base.sha !== baseSha || - currentPull.head.sha !== headSha || - currentPull.merge_commit_sha !== targetSha - ) { + if (!isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + targetSha, + )) { throw new Error( `repair payload no longer matches merged PR #${pullNumber}`, ); diff --git a/docs/automation.md b/docs/automation.md index e2efc038..87f0c911 100644 --- a/docs/automation.md +++ b/docs/automation.md @@ -216,8 +216,11 @@ repairing the producer. Never add a prefix `restore-keys` fallback to this path. `Coverage Baseline Repair` is the independent safety net for every merged PR. Its base-owned `pull_request_target: closed` job never checks out or executes PR -content: it binds the closed event to the current merged PR, verifies the exact -head/base/merge SHA and `main` containment, then emits only a +content: it binds the closed event's PR number and stable head SHA to the +current merged-PR facts (`merged_at`, `base.ref`, and `merge_commit_sha`) and +proves that merge commit is contained in `main`. It deliberately does not +compare REST `base.sha`, because that field follows the live base branch and +can move after the merge. Only then does it emit a `coverage-baseline-repair` repository dispatch. Workflow-skip directives alone do not suppress `pull_request_target`, subject to GitHub's separate security-sensitive branch-name restriction described above. The low-trust diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index 1b64ea74..00d043a7 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -309,9 +309,12 @@ merged SHA even if a human or integration changes the merge message after PR checks finish. Skip directives alone do not suppress `pull_request_target`, subject to GitHub's separate security-sensitive branch-name restriction above. That job executes no PR code and only dispatches after binding the exact -closed/merged PR head, base, merge SHA, and `main` containment. Because GitHub -makes default-branch caches read-only to `pull_request_target`, the dispatcher first -waits up to one minute for a run from the exact protected +closed-event PR number and stable head SHA to merged-PR facts +(`merged_at`, `base.ref`, and `merge_commit_sha`) and proving `main` +containment. It does not compare the later REST `base.sha`, which follows the +live base branch after merge. Because GitHub makes default-branch caches +read-only to `pull_request_target`, the dispatcher first waits up to one minute +for a run from the exact protected `.github/workflows/ci.yml` workflow and exits when that normal producer exists. A successful main CI hard-verifies the exact key itself. A completed non-success run starts a separate base-owned `workflow_run` dispatcher, which diff --git a/test/scripts/coverage_baseline_repair_identity_test.go b/test/scripts/coverage_baseline_repair_identity_test.go new file mode 100644 index 00000000..698fed25 --- /dev/null +++ b/test/scripts/coverage_baseline_repair_identity_test.go @@ -0,0 +1,145 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const stableMergedPRIdentityHelper = `function isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + mergeCommitSha, +) { + return ( + currentPull?.number === pullNumber && + currentPull.state === 'closed' && + currentPull.merged === true && + typeof currentPull.merged_at === 'string' && + currentPull.merged_at.length > 0 && + currentPull.base?.ref === 'main' && + currentPull.head?.sha === headSha && + currentPull.merge_commit_sha === mergeCommitSha + ); +}` + +func TestCoverageBaselineRepairUsesStableMergedPRIdentity(t *testing.T) { + t.Parallel() + + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node is required to verify merged-PR repair identity semantics") + } + root, err := filepath.Abs(filepath.Join("..", "..")) + if err != nil { + t.Fatalf("resolve repository root: %v", err) + } + workflowPath := filepath.Join( + root, + ".github", + "workflows", + "coverage-baseline-repair.yml", + ) + data, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("read %s: %v", workflowPath, err) + } + workflow := string(data) + + var parsedWorkflow any + if err := yaml.Unmarshal(data, &parsedWorkflow); err != nil { + t.Fatalf("parse %s: %v", workflowPath, err) + } + var scripts []string + collectGitHubScripts(parsedWorkflow, &scripts) + helperCount := 0 + checkCount := 0 + for _, script := range scripts { + helperCount += strings.Count(script, stableMergedPRIdentityHelper) + checkCount += strings.Count(script, "!isStableMergedPRIdentity(") + } + if helperCount != 2 { + t.Fatalf("stable merged-PR identity helper occurrences = %d, want dispatcher and producer", helperCount) + } + if checkCount != 2 { + t.Fatalf("stable merged-PR identity checks = %d, want dispatcher and producer", checkCount) + } + for _, forbidden := range []string{ + "currentPull.base.sha", + "const baseSha =", + "base_sha", + } { + if strings.Contains(workflow, forbidden) { + t.Errorf("merged-PR repair must not bind mutable Ref SHA via %q", forbidden) + } + } + for _, want := range []string{ + "const baseRef = eventPull?.base?.ref;", + "baseRef !== 'main'", + "const headSha = eventPull?.head?.sha;", + "const headSha = payload.head_sha;", + "head_sha: headSha", + "currentPull.head?.sha === headSha", + "currentPull.merge_commit_sha === mergeCommitSha", + "await requireMainContainment(mergeCommitSha)", + "await requireMainContainment(targetSha)", + } { + if !strings.Contains(workflow, want) { + t.Errorf("merged-PR repair missing stable identity guard %q", want) + } + } + + verification := stableMergedPRIdentityHelper + ` +const pullNumber = 1077; +const headSha = '1'.repeat(40); +const mergeCommitSha = '2'.repeat(40); +const validPull = { + number: pullNumber, + state: 'closed', + merged: true, + merged_at: '2026-08-25T04:55:30Z', + base: {ref: 'main', sha: '3'.repeat(40)}, + head: {sha: headSha}, + merge_commit_sha: mergeCommitSha, +}; +const cases = [ + ['base ref advanced after event', {...validPull, base: {...validPull.base, sha: '4'.repeat(40)}}, true], + ['base sha omitted by projection', {...validPull, base: {ref: 'main'}}, true], + ['wrong pull number', {...validPull, number: pullNumber + 1}, false], + ['open pull request', {...validPull, state: 'open'}, false], + ['not merged', {...validPull, merged: false}, false], + ['missing merged at', {...validPull, merged_at: null}, false], + ['empty merged at', {...validPull, merged_at: ''}, false], + ['non-string merged at', {...validPull, merged_at: 1}, false], + ['wrong base ref', {...validPull, base: {...validPull.base, ref: 'release'}}, false], + ['missing base ref', {...validPull, base: {}}, false], + ['missing head', {...validPull, head: null}, false], + ['wrong head sha', {...validPull, head: {sha: '5'.repeat(40)}}, false], + ['wrong merge sha', {...validPull, merge_commit_sha: '6'.repeat(40)}, false], + ['null pull', null, false], +]; +for (const [name, currentPull, want] of cases) { + const got = isStableMergedPRIdentity( + currentPull, + pullNumber, + headSha, + mergeCommitSha, + ); + if (got !== want) { + throw new Error(name + ': got ' + got + ', want ' + want); + } +} +` + command := exec.Command(node, "-e", verification) + if output, runErr := command.CombinedOutput(); runErr != nil { + t.Fatalf("stable merged-PR identity verification failed: %v\n%s", runErr, output) + } +} diff --git a/test/scripts/coverage_workflow_contract_test.go b/test/scripts/coverage_workflow_contract_test.go index 047569ea..facc8b8d 100644 --- a/test/scripts/coverage_workflow_contract_test.go +++ b/test/scripts/coverage_workflow_contract_test.go @@ -511,12 +511,15 @@ func TestCoverageBaselineRepairContract(t *testing.T) { "pull-requests: read", "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b", "context.payload.repository?.default_branch !== 'main'", - "currentPull.state !== 'closed'", - "currentPull.merged !== true", - "currentPull.base.ref !== 'main'", - "currentPull.base.sha !== baseSha", - "currentPull.head.sha !== headSha", - "currentPull.merge_commit_sha !== mergeCommitSha", + "currentPull.state === 'closed'", + "currentPull.merged === true", + "typeof currentPull.merged_at === 'string'", + "const baseRef = eventPull?.base?.ref;", + "baseRef !== 'main'", + "currentPull.base?.ref === 'main'", + "isStableMergedPRIdentity(", + "currentPull.head?.sha === headSha", + "currentPull.merge_commit_sha === mergeCommitSha", "for (let attempt = 1; attempt <= 6; attempt += 1)", "setTimeout(resolve, 5000)", "basehead: `${targetSha}...${branch.commit.sha}`", @@ -544,7 +547,6 @@ func TestCoverageBaselineRepairContract(t *testing.T) { "source: 'merged_pr'", "pull_number: String(pullNumber)", "head_sha: headSha", - "base_sha: baseSha", "merge_commit_sha: mergeCommitSha", } { if !strings.Contains(dispatcher, want) { @@ -557,12 +559,19 @@ func TestCoverageBaselineRepairContract(t *testing.T) { if ciWorkflowLookup < 0 || pushRunLookup <= ciWorkflowLookup || repairDispatch <= pushRunLookup { t.Error("merged-PR dispatcher must bind the fixed CI workflow before exhausting exact push-run lookup and dispatch") } + stableIdentityCheck := strings.Index(dispatcher, "if (!isStableMergedPRIdentity(") + mainContainmentCheck := strings.Index(dispatcher, "await requireMainContainment(mergeCommitSha)") + if stableIdentityCheck < 0 || mainContainmentCheck <= stableIdentityCheck || repairDispatch <= mainContainmentCheck { + t.Error("merged-PR dispatcher must prove stable PR identity and main containment before dispatch") + } for _, forbidden := range []string{ "actions/checkout@", "actions/cache/", "actions/setup-go@", "go test ", "github.event.pull_request.head.ref", + "currentPull.base.sha", + "base_sha", "secrets.", } { if strings.Contains(dispatcher, forbidden) { @@ -643,14 +652,14 @@ func TestCoverageBaselineRepairContract(t *testing.T) { "payload.source === 'merged_pr'", "payload.pull_number", "payload.head_sha", - "payload.base_sha", "payload.merge_commit_sha", - "currentPull.state !== 'closed'", - "currentPull.merged !== true", - "currentPull.base.ref !== 'main'", - "currentPull.base.sha !== baseSha", - "currentPull.head.sha !== headSha", - "currentPull.merge_commit_sha !== targetSha", + "currentPull.state === 'closed'", + "currentPull.merged === true", + "typeof currentPull.merged_at === 'string'", + "currentPull.base?.ref === 'main'", + "isStableMergedPRIdentity(", + "currentPull.head?.sha === headSha", + "currentPull.merge_commit_sha === mergeCommitSha", "payload.source === 'failed_ci'", "payload.workflow_run_id", "payload.workflow_run_attempt", @@ -700,6 +709,8 @@ func TestCoverageBaselineRepairContract(t *testing.T) { for _, forbidden := range []string{ "restore-keys", "github.event.pull_request.head.ref", + "currentPull.base.sha", + "base_sha", "HOMEBREW_PR_TOKEN", "RELEASE_GOVERNANCE_TOKEN", "REVIEWER_ROUTER_APP_PRIVATE_KEY", @@ -715,6 +726,14 @@ func TestCoverageBaselineRepairContract(t *testing.T) { if strings.Count(producer, "key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}") != 3 { t.Error("coverage repair restore, save, and verification must share one exact target key") } + stablePayloadIdentityCheck := strings.Index(producer, "if (!isStableMergedPRIdentity(") + mainPayloadContainmentCheck := strings.Index(producer, "await requireMainContainment(targetSha)") + resolvedTargetOutput := strings.Index(producer, "core.setOutput('target_sha', targetSha)") + if stablePayloadIdentityCheck < 0 || + mainPayloadContainmentCheck <= stablePayloadIdentityCheck || + resolvedTargetOutput <= mainPayloadContainmentCheck { + t.Error("merged-PR producer must prove stable payload identity and main containment before resolving checkout target") + } validateIndex := strings.Index(producer, "name: Resolve trusted main repair target") checkoutIndex := strings.Index(producer, "name: Check out exact protected-main target") if validateIndex < 0 || checkoutIndex <= validateIndex {