Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc332133a2 | ||
|
|
e615bd433c | ||
|
|
474ce88d47 | ||
|
|
b35d67b811 | ||
|
|
a14525ed1d | ||
|
|
816c356bbf | ||
|
|
f28dd6ee07 | ||
|
|
765338eb5b | ||
|
|
0bd767a3fe | ||
|
|
42627e769e | ||
|
|
3b22bb4994 | ||
|
|
6c192c2bf4 | ||
|
|
be5ce782b6 | ||
|
|
e1a50f08a6 | ||
|
|
d14ce3c8d2 | ||
|
|
b876b9b4ae | ||
|
|
82c6bcfcbf | ||
|
|
48a79b17c6 | ||
|
|
9b44eeb5b0 | ||
|
|
6de77f4c34 | ||
|
|
00f1379874 | ||
|
|
8687d68567 | ||
|
|
bf74159737 | ||
|
|
3ba0b90f9e | ||
|
|
c2a6ce01aa | ||
|
|
09a300867c | ||
|
|
73e010a992 | ||
|
|
809d026b7e | ||
|
|
3e9e76df2c | ||
|
|
fde008a25d | ||
|
|
eb5569ca21 | ||
|
|
b7c14c118f | ||
|
|
fb4cf70c93 | ||
|
|
890dfea477 | ||
|
|
b9f2733821 | ||
|
|
83543b20df | ||
|
|
4e4705c673 | ||
|
|
cd652bf9ab | ||
|
|
c5463424be |
@@ -0,0 +1,95 @@
|
||||
name: AI Behavior Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
ai-behavior-check:
|
||||
name: AI Behavior Policy Evaluator
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
# Deliberately do not check out or execute pull-request code here.
|
||||
# pull_request_target keeps this policy anchored to the base branch.
|
||||
- name: Check AI-generated PR boundaries
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const sha = context.payload.pull_request.head.sha;
|
||||
const setStatus = (state, description) =>
|
||||
github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha,
|
||||
state,
|
||||
context: 'AI Behavior Check',
|
||||
description,
|
||||
});
|
||||
|
||||
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
|
||||
|
||||
const labels = context.payload.pull_request.labels.map(({ name }) => name);
|
||||
if (!labels.includes('ai-generated')) {
|
||||
await setStatus('success', 'Not labeled ai-generated');
|
||||
core.notice('Not an ai-generated PR; no AI-only policy applied.');
|
||||
return;
|
||||
}
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const maxChangedFiles = 30;
|
||||
if (files.length > maxChangedFiles) {
|
||||
await setStatus(
|
||||
'failure',
|
||||
`Changes ${files.length} files; limit is ${maxChangedFiles}`
|
||||
);
|
||||
core.setFailed(
|
||||
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const isProtectedPath = (filename) =>
|
||||
typeof filename === 'string' &&
|
||||
(
|
||||
filename.startsWith('.github/workflows/') ||
|
||||
filename.startsWith('scripts/policy/') ||
|
||||
filename.startsWith('scripts/release/') ||
|
||||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
|
||||
filename === '.goreleaser.yaml' ||
|
||||
filename === 'Makefile'
|
||||
);
|
||||
const protectedPaths = [...new Set(
|
||||
files
|
||||
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
|
||||
.filter(isProtectedPath)
|
||||
)];
|
||||
|
||||
if (protectedPaths.length > 0) {
|
||||
await setStatus('failure', 'Modifies protected release/CI infrastructure');
|
||||
core.setFailed(
|
||||
'AI-generated PR modifies protected release/CI infrastructure:\n' +
|
||||
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
|
||||
'\nSplit these changes into a human-owned PR with explicit review.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await setStatus(
|
||||
'success',
|
||||
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
|
||||
);
|
||||
core.notice(
|
||||
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
|
||||
);
|
||||
+304
-39
@@ -7,8 +7,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -26,7 +25,7 @@ jobs:
|
||||
|
||||
- name: Format Check
|
||||
run: |
|
||||
unformatted="$(find cmd internal test -name '*.go' -print0 | xargs -0r gofmt -l)"
|
||||
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
|
||||
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
|
||||
|
||||
- name: Go Vet
|
||||
@@ -101,18 +100,108 @@ jobs:
|
||||
- name: Build Windows CLI
|
||||
run: go build -o dws.exe ./cmd
|
||||
|
||||
- name: Test Windows auth and DPAPI paths
|
||||
run: |
|
||||
go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
|
||||
- name: Test Windows auth and DPAPI packages
|
||||
run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
- name: Test Windows auth migration diagnostics
|
||||
run: go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
|
||||
|
||||
coverage-darwin:
|
||||
name: Coverage (macOS)
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run and enforce macOS changed-code coverage
|
||||
run: make coverage-gate-platform BASE_REF="$COVERAGE_BASE_REF" PROFILE=coverage-darwin.txt
|
||||
|
||||
- name: Upload macOS coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-darwin
|
||||
path: coverage-darwin.txt
|
||||
|
||||
coverage-windows:
|
||||
name: Coverage (Windows)
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Resolve authoritative coverage base
|
||||
shell: bash
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run and enforce Windows changed-code coverage
|
||||
shell: bash
|
||||
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
|
||||
|
||||
- name: Upload Windows coverage artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-windows
|
||||
path: coverage-windows.txt
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
@@ -125,11 +214,58 @@ jobs:
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Run tests with coverage
|
||||
- name: Resolve authoritative coverage base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
go test -coverprofile=coverage.txt -covermode=atomic ./cmd/... ./internal/...
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run current and baseline unit tests with coverage
|
||||
run: |
|
||||
set -eu
|
||||
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
|
||||
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/...
|
||||
go test -count=1 \
|
||||
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
|
||||
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
|
||||
-coverprofile=coverage-shortcut.txt \
|
||||
-covermode=atomic \
|
||||
./internal/app ./internal/helpers ./internal/shortcut/...
|
||||
|
||||
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
|
||||
rmdir "$base_worktree"
|
||||
cleanup() {
|
||||
git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
git worktree add --detach "$base_worktree" "$COVERAGE_BASE_REF"
|
||||
(
|
||||
cd "$base_worktree"
|
||||
go test -count=1 \
|
||||
-p 1 \
|
||||
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
go tool cover -func=coverage.txt
|
||||
|
||||
- name: Enforce coverage gate
|
||||
env:
|
||||
COVERAGE_TARGET: "80"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
|
||||
|
||||
- name: Generate coverage report
|
||||
run: go tool cover -html=coverage.txt -o coverage.html
|
||||
|
||||
@@ -139,32 +275,11 @@ jobs:
|
||||
name: coverage-report
|
||||
path: |
|
||||
coverage.txt
|
||||
coverage-base.txt
|
||||
coverage-policy.txt
|
||||
coverage-shortcut.txt
|
||||
coverage.html
|
||||
|
||||
- name: Update coverage badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
COVERAGE=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//')
|
||||
echo "Coverage: ${COVERAGE}%"
|
||||
if (( $(echo "$COVERAGE >= 80" | bc -l) )); then
|
||||
COLOR="brightgreen"
|
||||
elif (( $(echo "$COVERAGE >= 60" | bc -l) )); then
|
||||
COLOR="yellow"
|
||||
else
|
||||
COLOR="red"
|
||||
fi
|
||||
mkdir -p .github/badges
|
||||
curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" > .github/badges/coverage.svg
|
||||
|
||||
- name: Commit badge
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add .github/badges/coverage.svg || true
|
||||
git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]"
|
||||
git push || true
|
||||
|
||||
policy:
|
||||
name: Policy Check
|
||||
runs-on: ubuntu-latest
|
||||
@@ -184,8 +299,98 @@ jobs:
|
||||
- name: Policy
|
||||
run: make policy
|
||||
|
||||
- name: Generated Drift
|
||||
run: ./scripts/policy/check-generated-drift.sh
|
||||
interface-integrity:
|
||||
name: Interface Integrity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Resolve authoritative compatibility merge-base
|
||||
env:
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
base_ref="$PUSH_BEFORE_SHA"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||||
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
|
||||
fi
|
||||
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
|
||||
base_ref="$(git rev-parse HEAD^)"
|
||||
fi
|
||||
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
|
||||
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
|
||||
if [ -z "$stable_ref" ]; then
|
||||
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
|
||||
exit 1
|
||||
fi
|
||||
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
|
||||
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
|
||||
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Check historical commands and help compatibility
|
||||
run: |
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
|
||||
make authoritative-interface-integrity \
|
||||
BASE_REF="$COMPATIBILITY_STABLE_REF"
|
||||
fi
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
|
||||
- name: Check skill command references
|
||||
run: make skill-command-integrity
|
||||
|
||||
cli-smoke:
|
||||
name: CLI Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
|
||||
- name: Check public top-level commands
|
||||
run: make cli-smoke
|
||||
|
||||
mock-mcp-smoke:
|
||||
name: Mock MCP Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Check HTTP and stdio MCP transport
|
||||
run: make mock-mcp-smoke
|
||||
|
||||
edition-tests:
|
||||
name: Edition Contract Tests
|
||||
@@ -203,11 +408,71 @@ jobs:
|
||||
- name: Run edition contract tests
|
||||
run: go test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
ci-gate:
|
||||
name: CI Gate
|
||||
needs:
|
||||
- lint
|
||||
- test
|
||||
- test-darwin
|
||||
- test-windows
|
||||
- coverage
|
||||
- coverage-darwin
|
||||
- coverage-windows
|
||||
- policy
|
||||
- interface-integrity
|
||||
- cli-smoke
|
||||
- mock-mcp-smoke
|
||||
- edition-tests
|
||||
if: ${{ always() }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Verify required checks
|
||||
env:
|
||||
LINT_RESULT: ${{ needs.lint.result }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
|
||||
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
|
||||
COVERAGE_RESULT: ${{ needs.coverage.result }}
|
||||
COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||
POLICY_RESULT: ${{ needs.policy.result }}
|
||||
INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }}
|
||||
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
|
||||
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
|
||||
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
|
||||
run: |
|
||||
failed=0
|
||||
for check in \
|
||||
"Lint:$LINT_RESULT" \
|
||||
"Test:$TEST_RESULT" \
|
||||
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
|
||||
"Test (Windows):$TEST_WINDOWS_RESULT" \
|
||||
"Coverage:$COVERAGE_RESULT" \
|
||||
"Coverage (macOS):$COVERAGE_DARWIN_RESULT" \
|
||||
"Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \
|
||||
"Policy Check:$POLICY_RESULT" \
|
||||
"Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \
|
||||
"CLI Smoke:$CLI_SMOKE_RESULT" \
|
||||
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \
|
||||
"Edition Contract Tests:$EDITION_TESTS_RESULT"
|
||||
do
|
||||
name="${check%%:*}"
|
||||
result="${check#*:}"
|
||||
printf '%s: %s\n' "$name" "$result"
|
||||
if [ "$result" != "success" ]; then
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
notify-downstream:
|
||||
name: Notify Wukong Overlay
|
||||
needs: [test, policy, edition-tests]
|
||||
needs: [ci-gate]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Trigger downstream CI
|
||||
run: |
|
||||
|
||||
@@ -42,8 +42,7 @@ jobs:
|
||||
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
|
||||
|
||||
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
|
||||
git fetch --force --tags origin "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
git push --force "$REMOTE" "refs/tags/${GITHUB_REF_NAME}:refs/tags/${GITHUB_REF_NAME}"
|
||||
VERSION="$GITHUB_REF_NAME" ./scripts/release/sync-gitee-tag.sh
|
||||
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
|
||||
exit 0
|
||||
fi
|
||||
@@ -83,5 +82,7 @@ jobs:
|
||||
|
||||
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
git push --force --tags "$REMOTE"
|
||||
# Release tags are immutable. Push only missing tags and fail closed
|
||||
# on a conflicting existing ref instead of trying to move it.
|
||||
timeout --signal=TERM 180s git push --tags "$REMOTE"
|
||||
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
|
||||
|
||||
@@ -26,6 +26,19 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check Homebrew PR automation token
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
# Organization policy intentionally prevents the broad, built-in
|
||||
# GITHUB_TOKEN from creating PRs. Keep Formula automation on a
|
||||
# repository-scoped token instead of weakening that policy.
|
||||
env:
|
||||
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
run: |
|
||||
if [ -z "${HOMEBREW_PR_TOKEN:-}" ]; then
|
||||
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
@@ -157,9 +170,9 @@ jobs:
|
||||
- release
|
||||
- verify-darwin-signatures
|
||||
runs-on: ubuntu-latest
|
||||
# Mirroring every release asset to Gitee can be slow; 30 minutes previously
|
||||
# cut the fallback upload off mid-run.
|
||||
timeout-minutes: 60
|
||||
# The optional Gitee fallback has its own bounded retry budget and may be
|
||||
# enabled during a cross-border incident. Normal releases skip that step.
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
@@ -176,6 +189,34 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
|
||||
|
||||
- name: Open stable Homebrew formula PR
|
||||
# Beta builds must never replace the stable Homebrew formula. Formula
|
||||
# updates use the normal PR path instead of writing main from a tag job.
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
|
||||
run: ./scripts/release/publish-homebrew-formula.sh
|
||||
env:
|
||||
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
|
||||
DWS_TAP_PR_BRANCH: "automation/homebrew-${{ github.ref_name }}"
|
||||
DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ github.ref_name }}"
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ github.ref_name }}"
|
||||
|
||||
- name: Open beta Homebrew formula PR
|
||||
# Keep beta in a separately named, keg-only Formula so it cannot replace the
|
||||
# stable dws link for ordinary Homebrew users.
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }}
|
||||
run: ./scripts/release/publish-homebrew-formula.sh
|
||||
env:
|
||||
DWS_FORMULA_SOURCE: dist/homebrew/dingtalk-workspace-cli-beta.rb
|
||||
DWS_TAP_FORMULA_PATH: Formula/dingtalk-workspace-cli-beta.rb
|
||||
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
|
||||
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
|
||||
DWS_TAP_PR_BRANCH: "automation/homebrew-beta-${{ github.ref_name }}"
|
||||
DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ github.ref_name }}"
|
||||
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ github.ref_name }}"
|
||||
|
||||
- name: Sync release to China OSS mirror
|
||||
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
|
||||
# 脚本自带门控:未配置 OSS_* secret 时优雅跳过,不影响海外发布。
|
||||
@@ -211,13 +252,29 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
mirror-gitee-release:
|
||||
# Keep the optional cross-border fallback out of publish-release so all
|
||||
# pre-sync work has an independently provable budget.
|
||||
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
|
||||
needs:
|
||||
- release
|
||||
- publish-release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Restore finalized distribution files
|
||||
uses: actions/download-artifact@v4
|
||||
timeout-minutes: 10
|
||||
with:
|
||||
name: finalized-release-dist
|
||||
path: dist
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# 把 release 附件(二进制/校验和/skills 包)镜像到 Gitee release,供 install.sh
|
||||
# 的 DWS_GITEE_REPO 开关消费(仓库代码由 Gitee 仓库镜像功能自动同步,附件不在其内)。
|
||||
# 默认关闭:国内 release 应由 Gitee 侧本地构建发布,避免 GitHub -> Gitee 跨境传大包卡住。
|
||||
# 仅在需要临时补救时设置 repo variable ENABLE_GITEE_UPLOAD_FALLBACK=true。
|
||||
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 100
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
|
||||
@@ -21,12 +21,16 @@ permissions:
|
||||
jobs:
|
||||
sync-gitee:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
# Each step has its own ceiling. Their 115-minute sum leaves five minutes
|
||||
# for runner scheduling/teardown inside this 120-minute job deadline.
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
timeout-minutes: 5
|
||||
|
||||
- name: Download GitHub release assets
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
@@ -42,6 +46,7 @@ jobs:
|
||||
|
||||
- name: Mirror release to Gitee (China)
|
||||
# Idempotent: uploads only assets not already present on the Gitee release.
|
||||
timeout-minutes: 100
|
||||
run: ./scripts/release/sync-to-gitee.sh
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
|
||||
@@ -19,6 +19,7 @@ test/cli_compat/testdata/
|
||||
/internal/compat/testdata/*
|
||||
.gitignore
|
||||
.worktrees/
|
||||
.qoder/
|
||||
|
||||
# Secrets & credentials
|
||||
.env
|
||||
@@ -47,4 +48,9 @@ test/dev_functional/results.jsonl
|
||||
/.qoder/
|
||||
.vercel
|
||||
.env*
|
||||
# Local Go coverage output
|
||||
/coverage.txt
|
||||
/coverage-base.txt
|
||||
/coverage-policy.txt
|
||||
/coverage.html
|
||||
dwsbin
|
||||
|
||||
@@ -6,6 +6,16 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Declarative shortcut commands** (#592) — adds 366 `dws <service> +<command>` shortcuts across 16 services, including one-to-one MCP wrappers and multi-step smart workflows. Shortcuts publish stable Agent-visible contracts with named flags, validation and confirmation metadata, dry-run protection for writes, catalog/help routing, and optional local YAML extensions and usage recording.
|
||||
- **Sheet imports and Aitable workflow writes** (#624) — adds `dws sheet import` / `sheet import create` for converting local xlsx/xls files into new online sheets, `sheet import get` for polling import tasks, and `dws aitable workflow create/update` for applying validated `workflow-dsl/v1` definitions, with matching reviewed Agent Schema and bundled Skill guidance.
|
||||
- **Official multi-platform Homebrew channel** — stable `Formula/dingtalk-workspace-cli.rb` and keg-only `Formula/dingtalk-workspace-cli-beta.rb` live in this repository and select signed macOS Intel/Apple Silicon or Linux amd64/arm64 artifacts at install time. Stable and beta releases open isolated Formula update PRs after final artifact signing, so beta never replaces the stable Formula. Agent Skills stay under `pkgshare` without mutating the user's home directory, and both tracks are covered by the six-channel post-release verifier.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sheet and Todo invalid-target failures** — `sheet range read/get` now rejects a null cell-info response instead of printing `null` and exiting successfully, while Todo completion and attachment listing verify that a task exists before calling lenient backend endpoints. Attachment listing is also published through Runtime Schema for schema-first Agent discovery.
|
||||
|
||||
## [1.0.52] - 2026-07-14
|
||||
|
||||
This release seals the `v1.0.52` line with personal event subscriptions, a deterministic 22-product Agent command catalog, local user-operation auditing, expanded Open product commands, safer macOS credentials and release signing, and more reliable Connect and IM delivery.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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.52-beta.5"
|
||||
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.52-beta.5/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7164f2b0389ce0c3bc1d745b5c98082c1ef92c8547c9b123dcb4e83fe172f92e"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-darwin-amd64.tar.gz"
|
||||
sha256 "6ebd48fb96009cf2a81eb0af15216ba050620db55470d5c9937467aa66558879"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-linux-arm64.tar.gz"
|
||||
sha256 "5f718244665c33a9327130874788d0fad36824ec29eb437ab82aa83e3d5a0579"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-linux-amd64.tar.gz"
|
||||
sha256 "e79abccc1e093b946be89282bd034ba60ab479cc8ee1a51001eb0d441c66125c"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-skills.zip"
|
||||
sha256 "64c48271de89a94f9c184a475692e0e2f5e23bc0480c10824f717b21e3a83097"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
This beta is keg-only. Add #{opt_bin} to PATH to use its `dws` binary.
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.52"
|
||||
license "Apache-2.0"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-arm64.tar.gz"
|
||||
sha256 "4f6b4d064a76bcefac42feb5f356253fe43f9499b8cec9d2cdf202e7d3b9b60c"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz"
|
||||
sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-arm64.tar.gz"
|
||||
sha256 "0d357ef0535f99f2f63b5ecbfdee9c32448be2a2c24f3096c03126b3b7570bc5"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz"
|
||||
sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-skills.zip"
|
||||
sha256 "0fa3c8dec500c1659e6480d6772ae901b2d12d24322dd5d7283f016024290c21"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,9 @@
|
||||
GO ?= go
|
||||
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
|
||||
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -10,7 +13,17 @@ help:
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make lint - Run formatting checks and golangci-lint when available\n"
|
||||
@printf " make fmt - Format Go source files\n"
|
||||
@printf " make policy - Run open-source asset and Schema registry checks\n"
|
||||
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
|
||||
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce native-platform changed-code coverage\n"
|
||||
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n"
|
||||
@printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n"
|
||||
@@ -32,20 +45,51 @@ lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
fmt:
|
||||
@find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
|
||||
@find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
|
||||
|
||||
policy:
|
||||
@./scripts/policy/check-open-source-assets.sh
|
||||
@./scripts/policy/check-schema-command-registry.sh
|
||||
@./scripts/policy/check-command-surface.sh --strict
|
||||
@./scripts/policy/check-generated-drift.sh
|
||||
@./scripts/policy/check-schema-catalog.sh
|
||||
@./scripts/policy/check-schema-binary.sh
|
||||
@$(MAKE) test-schema-agent-examples
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
|
||||
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
|
||||
|
||||
edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
interface-integrity:
|
||||
@./scripts/policy/check-interface-baseline.sh
|
||||
|
||||
authoritative-interface-integrity:
|
||||
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
|
||||
|
||||
coverage-gate:
|
||||
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
|
||||
|
||||
coverage-gate-platform:
|
||||
@./scripts/policy/run-platform-coverage-gate.sh --base-ref "$(BASE_REF)" --profile "$(PROFILE)"
|
||||
|
||||
update-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --update
|
||||
|
||||
reset-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --reset
|
||||
|
||||
schema-compatibility:
|
||||
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
|
||||
|
||||
skill-command-integrity:
|
||||
@./scripts/policy/check-skill-commands.sh
|
||||
|
||||
cli-smoke:
|
||||
@./scripts/policy/check-cli-smoke.sh
|
||||
|
||||
mock-mcp-smoke:
|
||||
$(GO) test -v -count=1 -run '^(TestHTTPClientEndToEnd|TestStdioClientEndToEnd)$$' ./internal/transport
|
||||
|
||||
test-schema-agent-examples:
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestManualAgentExamplesDryRun$$'
|
||||
|
||||
|
||||
@@ -93,6 +93,30 @@ How to pick:
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
Install the latest beta:
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli@beta
|
||||
```
|
||||
|
||||
**Homebrew** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
brew install dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
> The Formula lives in this repository, so the first `tap` command must include the explicit repository URL. Afterwards, use `brew upgrade dingtalk-workspace-cli` normally.
|
||||
|
||||
Install the keg-only Homebrew beta without replacing the stable Formula:
|
||||
|
||||
```bash
|
||||
brew install dingtalk-workspace-cli-beta
|
||||
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
|
||||
```
|
||||
|
||||
To make the beta `dws` the default for the current shell, prepend `$(brew --prefix dingtalk-workspace-cli-beta)/bin` to PATH.
|
||||
|
||||
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
|
||||
|
||||
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
|
||||
@@ -168,6 +192,18 @@ dws upgrade -y # skip confirmation prompt
|
||||
|
||||
By default, `dws upgrade` follows the stable release track. Use `--beta` only when you explicitly want the newest GitHub pre-release build.
|
||||
|
||||
### Six-channel post-release verification
|
||||
|
||||
Maintainers and release validators can run the release-quality smoke checks for curl, PowerShell, npm stable, npm beta, Homebrew, and `dws upgrade`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
|
||||
cd /tmp/dws-verify/verify
|
||||
bash verify-all-channels.sh
|
||||
```
|
||||
|
||||
The verifier uses isolated directories and does not replace the `dws` on the current PATH. It reports `PASS`, `FAIL`, and `SKIP`; a platform skip is not a pass and must be covered on the matching host. See [`verify/README.md`](verify/README.md) for the platform matrix.
|
||||
|
||||
<details>
|
||||
<summary><strong>How it works</strong></summary>
|
||||
|
||||
|
||||
@@ -93,6 +93,30 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
npm install -g dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
安装最新 beta:
|
||||
|
||||
```bash
|
||||
npm install -g dingtalk-workspace-cli@beta
|
||||
```
|
||||
|
||||
**Homebrew**(macOS / Linux):
|
||||
|
||||
```bash
|
||||
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
|
||||
brew install dingtalk-workspace-cli
|
||||
```
|
||||
|
||||
> Formula 与代码位于同一个仓库,因此首次 `tap` 需要显式指定仓库 URL。后续可直接使用 `brew upgrade dingtalk-workspace-cli`。
|
||||
|
||||
安装 Homebrew beta(keg-only,不覆盖稳定版):
|
||||
|
||||
```bash
|
||||
brew install dingtalk-workspace-cli-beta
|
||||
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
|
||||
```
|
||||
|
||||
如需让 beta 的 `dws` 成为当前 shell 默认版本,将 `$(brew --prefix dingtalk-workspace-cli-beta)/bin` 放到 PATH 最前面。
|
||||
|
||||
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
|
||||
|
||||
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
|
||||
@@ -165,6 +189,18 @@ dws upgrade -y # 跳过确认直接升级
|
||||
|
||||
默认情况下,`dws upgrade` 只跟随正式 release 轨道。只有显式传入 `--beta` 时,才会选择 GitHub pre-release 里的 beta 构建。
|
||||
|
||||
### 六渠道发布后验证
|
||||
|
||||
维护者和验证同学可按发版质量保障 SOP,对 curl、PowerShell、npm stable、npm beta、Homebrew、`dws upgrade` 执行安装与冒烟验证:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
|
||||
cd /tmp/dws-verify/verify
|
||||
bash verify-all-channels.sh
|
||||
```
|
||||
|
||||
脚本使用隔离目录,不会替换当前 PATH 中的 `dws`;输出 `PASS`、`FAIL`、`SKIP` 汇总。跨平台渠道必须由对应平台补测,`SKIP` 不计为通过。验证范围和平台矩阵见 [`verify/README.md`](verify/README.md)。
|
||||
|
||||
<details>
|
||||
<summary><strong>工作原理</strong></summary>
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
class __CLASS_NAME__ < Formula
|
||||
desc "__DESCRIPTION__"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "__VERSION__"
|
||||
license "Apache-2.0"
|
||||
__KEG_ONLY_LINE__
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "__DARWIN_ARM64_URL__"
|
||||
sha256 "__DARWIN_ARM64_SHA256__"
|
||||
else
|
||||
url "__DARWIN_AMD64_URL__"
|
||||
sha256 "__DARWIN_AMD64_SHA256__"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "__LINUX_ARM64_URL__"
|
||||
sha256 "__LINUX_ARM64_SHA256__"
|
||||
else
|
||||
url "__LINUX_AMD64_URL__"
|
||||
sha256 "__LINUX_AMD64_SHA256__"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "__SKILLS_URL__"
|
||||
sha256 "__SKILLS_SHA256__"
|
||||
end
|
||||
|
||||
def install
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
|
||||
bin.install binary => "dws"
|
||||
|
||||
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
|
||||
source = File.join(root, name)
|
||||
pkgshare.install source if File.exist?(source)
|
||||
end
|
||||
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
__CHANNEL_CAVEAT__
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dws version")
|
||||
end
|
||||
end
|
||||
+7
-38
@@ -1,5 +1,5 @@
|
||||
class __CLASS_NAME__ < Formula
|
||||
desc "DingTalk Workspace CLI"
|
||||
desc "Install locally built DingTalk workspace CLI artifacts for verification"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
url "__ARCHIVE_URL__"
|
||||
sha256 "__ARCHIVE_SHA256__"
|
||||
@@ -12,8 +12,6 @@ __KEG_ONLY_LINE__
|
||||
end
|
||||
|
||||
def install
|
||||
require "fileutils"
|
||||
|
||||
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
|
||||
binary = File.join(root, "dws")
|
||||
raise "binary not found: #{binary}" unless File.exist?(binary)
|
||||
@@ -28,44 +26,15 @@ __KEG_ONLY_LINE__
|
||||
skill_dest = pkgshare/"skills/dws"
|
||||
skill_dest.mkpath
|
||||
resource("skills").stage do
|
||||
FileUtils.cp_r(Dir["*"], skill_dest)
|
||||
cp_r(Dir["*"], skill_dest)
|
||||
end
|
||||
end
|
||||
|
||||
def post_install
|
||||
require "fileutils"
|
||||
|
||||
skill_root = pkgshare/"skills/dws"
|
||||
entries = Dir["#{skill_root}/*"]
|
||||
return if entries.empty?
|
||||
|
||||
targets = [
|
||||
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoder/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".qoderwork/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".windsurf/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".augment/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".cline/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
|
||||
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
|
||||
]
|
||||
|
||||
targets.each_with_index do |dest, index|
|
||||
parent_gate = dest.parent.parent
|
||||
next if index > 0 && !parent_gate.directory?
|
||||
|
||||
FileUtils.rm_rf(dest)
|
||||
FileUtils.mkdir_p(dest)
|
||||
FileUtils.cp_r(entries, dest)
|
||||
end
|
||||
def caveats
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
EOS
|
||||
end
|
||||
|
||||
test do
|
||||
|
||||
@@ -62,6 +62,24 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Homebrew Formula PR Automation
|
||||
|
||||
Official tag releases require the repository Actions secret
|
||||
`HOMEBREW_PR_TOKEN`. The `DingTalk-Real-AI` organization currently does not
|
||||
allow fine-grained personal access tokens to target this repository, so use a
|
||||
classic personal access token owned by a maintainer or release-bot account with
|
||||
only the `public_repo` scope. Do not reuse a broad developer token.
|
||||
|
||||
Store the non-expiring token as the `HOMEBREW_PR_TOKEN` repository Actions
|
||||
secret. Replace it immediately if it is exposed, its owner loses repository
|
||||
access, or the release-bot ownership changes. The Release workflow uses this
|
||||
dedicated token only to push an `automation/homebrew-*` branch and open the
|
||||
stable or beta Formula PR. It does not push Formula changes directly to `main`.
|
||||
No maintainer environment variable is required when creating a tag. Using the
|
||||
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
|
||||
Actions from creating pull requests, and its generated PR events may require
|
||||
separate workflow approval.
|
||||
|
||||
## Handoff Checklist
|
||||
|
||||
Before handoff, include:
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Pull request quality gates
|
||||
|
||||
The repository defines five focused checks in addition to its existing CI:
|
||||
|
||||
- **Interface Integrity** enforces backwards compatibility. Every historical
|
||||
command path and alias must still resolve, every historical command must
|
||||
still render `-h`, and historical flags must keep their type and shorthand.
|
||||
New commands, aliases, and flags are allowed. The same job compares the full
|
||||
complete `dws schema --all` contract with the PR merge-base, blocking removed
|
||||
products/tools/parameters, incompatible parameter or interface mappings,
|
||||
constraint drift, and safety-semantic drift. It also checks that executable
|
||||
`dws ...` references in `skills/**/*.md` resolve to real commands.
|
||||
Help compatibility covers command/alias/flag spelling, flag type and
|
||||
shorthand; descriptive prose may evolve without breaking the gate.
|
||||
- **Coverage** runs unit tests on every pull request and prints both overall and
|
||||
changed-code statement coverage. During the migration to the 80% repository
|
||||
target, overall coverage may not regress from a profile generated from the
|
||||
merge-base with the same test command, while changed production Go
|
||||
statements must meet 80%. Linux, Windows, and macOS each generate a native
|
||||
coverage profile for changed packages and enforce the threshold against
|
||||
changed files buildable on that platform, so build-tagged source cannot be
|
||||
hidden by an Ubuntu-only profile. Overall non-regression allows 0.1 percentage point of measurement
|
||||
variance to avoid failing unchanged code on test-path noise. Set
|
||||
`COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make
|
||||
the overall target fail closed as well.
|
||||
- **CLI Smoke** builds the release binary, reads the root command list from the
|
||||
structured Interface contract, and renders offline help for every public
|
||||
top-level command. It rejects Cobra's unknown-command root-help fallback and
|
||||
fails when the checked-in development fixture is stale.
|
||||
- **Mock MCP Smoke** runs the existing HTTP and stdio MCP lifecycle tests
|
||||
(`Initialize -> ListTools -> CallTool`).
|
||||
- **AI Behavior Check** applies to pull requests labeled `ai-generated`. It
|
||||
limits the change to 30 files and blocks release/CI infrastructure changes,
|
||||
including policy implementations and the checked-in Interface fixture.
|
||||
It uses `pull_request_target` without checking out PR code, so the policy
|
||||
cannot be bypassed by changing the workflow in the same pull request. The
|
||||
evaluator writes an `AI Behavior Check` commit status to the PR head SHA so
|
||||
GitHub rulesets can require it.
|
||||
|
||||
## Running the compatibility gates
|
||||
|
||||
Run:
|
||||
|
||||
```sh
|
||||
make build
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
# Run on the corresponding native runner with its generated profile:
|
||||
make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
|
||||
```
|
||||
|
||||
`make coverage-gate` is the enforcement step, not a profile generator. It
|
||||
expects the candidate, policy, and merge-base profiles (`coverage.txt`,
|
||||
`coverage-policy.txt`, and `coverage-base.txt`) produced by the preceding CI
|
||||
steps. A clean local checkout can reproduce the Linux/overall CI gate with:
|
||||
|
||||
```sh
|
||||
base_ref=$(git merge-base HEAD origin/main)
|
||||
root=$(pwd)
|
||||
base_worktree=$(mktemp -d "${TMPDIR:-/tmp}/dws-coverage-base.XXXXXX")
|
||||
rmdir "$base_worktree"
|
||||
cleanup() { git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
go test -count=1 -coverprofile=coverage.txt -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \
|
||||
./pkg/... ./scripts/policy/...
|
||||
git worktree add --detach "$base_worktree" "$base_ref"
|
||||
(
|
||||
cd "$base_worktree"
|
||||
go test -count=1 -coverprofile="$root/coverage-base.txt" -covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
)
|
||||
make coverage-gate BASE_REF="$base_ref"
|
||||
```
|
||||
|
||||
The native-platform target likewise expects `PROFILE` to have already been
|
||||
generated on that operating system. CI owns those generation steps; copying
|
||||
only either enforcement command into a clean checkout is intentionally an
|
||||
incomplete invocation.
|
||||
|
||||
CI derives the authoritative Interface snapshots from both the PR merge-base
|
||||
and the latest reachable stable release tag. The complete Schema snapshot comes
|
||||
from the PR merge-base, which contains the registry-first Schema introduced on
|
||||
`main`. The candidate branch cannot bless a breaking change by editing a
|
||||
fixture. Schema additions are allowed; historical products, tools, parameters,
|
||||
parameter mappings, positional execution fields, constraints, and safety
|
||||
semantics remain protected. Positional descriptions are documentation and may
|
||||
change without breaking compatibility.
|
||||
|
||||
`make update-interface-baseline` still extends the local checked-in Interface
|
||||
fixture used by `make interface-integrity`. Updates are monotonic: they add new
|
||||
commands and flags without removing history.
|
||||
|
||||
For an intentional compatibility reset at a major-version boundary, run
|
||||
`make reset-interface-baseline`. This replaces all CLI compatibility history
|
||||
with the current command tree and must receive explicit human review.
|
||||
|
||||
## Required GitHub repository settings
|
||||
|
||||
Create a ruleset for `main` that requires pull requests and code-owner review,
|
||||
then mark these aggregate status checks as required:
|
||||
|
||||
- `CI Gate`
|
||||
- `Multi Profile E2E`
|
||||
- `AI Behavior Check`
|
||||
|
||||
`CI Gate` fails closed unless every first-layer CI job succeeds, including
|
||||
lint, tests, native Linux/Windows/macOS coverage, policy,
|
||||
Interface/Schema/Skill integrity, and smoke tests. Requiring the aggregate
|
||||
check keeps repository rules stable when an internal job is renamed or split.
|
||||
|
||||
The `ai-generated` label must be applied by the PR-creation automation or by a
|
||||
maintainer; GitHub cannot infer reliably whether a human-authored PR contains
|
||||
AI-generated code.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Shortcut 真实测试:后端 / MCP 问题整理
|
||||
|
||||
这份报告只汇总 `failure_category = backend-or-mcp-error` 的 case,已尽量排除权限、缺真实资源、当前账号无数据等噪音。
|
||||
|
||||
## 总览
|
||||
|
||||
- Backend/MCP case 总数:33
|
||||
- 聚合问题数:8
|
||||
- 复现口径:真实 dws CLI;无 mock;无 dry-run;命令输入和 trace_id 均来自真实测试结果。
|
||||
|
||||
## 建议优先看
|
||||
|
||||
1. [P1] Chat/IM 会话 ID 字段在 MCP/后端映射中疑似丢失(15 case)
|
||||
2. [P1] Chat card 发送 receiverUid 疑似未从 receiver 透传(1 case)
|
||||
3. [P1] Chat 入群审批 applicantUid/inviterUid 疑似未透传(1 case)
|
||||
4. [P1] AI 表格 MCP 错误 envelope 语义不一致:success=true 但 error 非空/status=error(5 case)
|
||||
5. [P1] AI 表格 Workflow 查询在真实 Base 下返回系统级错误(2 case)
|
||||
6. [P1] AI 表格 roleId 参数疑似未被 MCP 正确读取(3 case)
|
||||
7. [P2] AI 表格记录主文档查询在真实 record 下返回 no record/SYSTEM_ERROR(2 case)
|
||||
8. [P2] AI 表格无效 Base/Table/Field/Record 被包装成 SYSTEM_ERROR(4 case)
|
||||
|
||||
## Chat/IM 会话 ID 字段在 MCP/后端映射中疑似丢失
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:IM MCP / IM 后端字段映射
|
||||
- 现象:CLI 已传 group/conversation-id/open-conversation-id(部分 case 使用真实 cid),后端仍报 openCid/openConversationId/cid required。
|
||||
- 期望:MCP schema/网关应接受并透传 openConversationId/openCid/cid 中的兼容字段;如果资源无效,应返回“无效会话”,而不是 required。
|
||||
- 涉及 case:15
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `chat +chat-members-get` | `tools/call` | `2127d89817840997754345760e07bd` | [UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-members-get --id DWSREALREADNOSUCHID0000000000000 --users '冬翔' --yes --format json` |
|
||||
| `read` | `chat +chat-messages` | `tools/call` | `2104a64c17840997767792656e085e` | [UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-messages --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --limit 10 --direction older --yes --format json` |
|
||||
| `read` | `chat +messages-list` | `tools/call` | `2127d89817840997797873841e0757` | [UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-list --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --forward --limit 10 --yes --format json` |
|
||||
| `write` | `chat +chat-mute-member` | `tools/call` | `2104a64c17840999166036583e08a3` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-mute-member --group cidDWSREALTESTNOSUCHCONV --users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --mute-time 1 --off --yes --format json` |
|
||||
| `write` | `chat +chat-transfer-owner` | `tools/call` | `0b5deb3217840999222318863e087a` | [UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-transfer-owner --group cidDWSREALTESTNOSUCHCONV --new-owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json` |
|
||||
| `write` | `chat +conversation-clear-messages` | `tools/call` | `2127d89817840999254816721e079b` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-clear-messages --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-clear-red-point` | `tools/call` | `2104a64c17840999265511295e085f` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-clear-red-point --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-hide` | `tools/call` | `2127d89817840999276117140e079b` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-hide --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-mark-unread` | `tools/call` | `0bb7c36217840999298744910e0758` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mark-unread --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
|
||||
| `write` | `chat +conversation-mute` | `tools/call` | `2104a64c17840999309241865e085f` | [UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +conversation-mute-at-all` | `tools/call` | `2127d89817840999320028068e07dd` | [UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute-at-all --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +conversation-mute-red-envelope` | `tools/call` | `0bb7c36217840999330228283e07fe` | [UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute-red-envelope --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +conversation-set-top` | `tools/call` | `2127d89817840999341302961e07fe` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +conversation-set-top --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
|
||||
| `write` | `chat +messages-set-pin` | `tools/call` | `0bb7c36217840999521117991e0758` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-set-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `chat +messages-unset-pin` | `tools/call` | `2104a64c17840999544428103e08ee` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-unset-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
|
||||
## Chat card 发送 receiverUid 疑似未从 receiver 透传
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:IM MCP / card 发送参数映射
|
||||
- 现象:CLI 传入 receiver=103262,后端仍报 receiverUid 和 openConversationId 不能同时为空。
|
||||
- 期望:receiver 应映射为 receiverUid,或 schema 明确要求 receiverUid;真实入参不应在 MCP 层丢失。
|
||||
- 涉及 case:1
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `write` | `chat +messages-send-card` | `tools/call` | `2104a64c17840999509255753e081a` | [UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +messages-send-card --receiver 103262 --yes --format json` |
|
||||
|
||||
## Chat 入群审批 applicantUid/inviterUid 疑似未透传
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:IM MCP / 入群审批参数映射
|
||||
- 现象:CLI 传入 applicant=103262、inviter=519019,后端仍报 applicantUid required。
|
||||
- 期望:applicant/inviter 应映射为 applicantUid/inviterUid;如果 recordId/group 无效,应返回对应资源错误而不是 applicantUid 缺失。
|
||||
- 涉及 case:1
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `write` | `chat +chat-audit-join` | `tools/call` | `0bb7c36217840999154832733e0758` | [UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group) hint: Use --verbose for detailed error logs |
|
||||
| | input | | | `/private/tmp/dws-real-test chat +chat-audit-join --group cidDWSREALTESTNOSUCHCONV --record-id 999999999999 --applicant 103262 --inviter 519019 --status AuditApprove --description 'DWS shortcut 真实测试描述,可删除' --yes --format json` |
|
||||
|
||||
## AI 表格 MCP 错误 envelope 语义不一致:success=true 但 error 非空/status=error
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:AI 表格 MCP wrapper
|
||||
- 现象:多条 AI 表格命令返回 MCP_TOOL_ERROR,内部 JSON 同时出现 success=true、status=error、error 非空。
|
||||
- 期望:只要 error 非空或 status=error,success 应为 false,外层也应按业务错误返回稳定错误码/trace。
|
||||
- 涉及 case:5
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +export-data` | `-` | `2104a64c17840997514714448e0817` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"taskId cannot be combined with scope, format, tableId or viewId","retryable":false,"type":"INPUT_ERROR"},"m… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +export-data --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --task-id DWSREALREADNOSUCHID0000000000000 --scope all --format excel --table-id hERWDMS --view-id qvGDAH2 --timeout-ms 1 --yes` |
|
||||
| `write` | `aitable +chart-update` | `-` | `2106d98117840998553244877e08df` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"config is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +chart-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --config '{}' --layout '{}' --yes --format json` |
|
||||
| `write` | `aitable +record-update` | `-` | `0bab027317840998747383236e090b` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_RECORDS","message":"records must contain at least one writable record","retryable":false,"type":"INPUT_ERROR"},"meta":{},"stat… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[{"recordId":"recDWSREALTEST","cells":{}}]' --yes --format json` |
|
||||
| `write` | `aitable +record-upsert` | `-` | `2106d98117840998759832182e087b` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMETER","message":"records is required and must not be empty","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"e… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-upsert --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[]' --yes --format json` |
|
||||
| `write` | `aitable +view-set-fill-color-rule` | `-` | `2106d98117840998924181709e08df` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"conditionalFormats is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +view-set-fill-color-rule --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --json '{}' --yes --format json` |
|
||||
|
||||
## AI 表格 Workflow 查询在真实 Base 下返回系统级错误
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:AI 表格 Workflow MCP / 后端
|
||||
- 现象:使用真实可访问 Base 查询 workflow list/get,返回 LIST_WORKFLOWS_ERROR/GET_WORKFLOW_ERROR。
|
||||
- 期望:无 workflow 时应返回空列表或 WORKFLOW_NOT_FOUND;有后端异常时需提供稳定错误码和可排查 trace。
|
||||
- 涉及 case:2
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +workflow-get` | `-` | `2104a64c17840997556363676e08ee` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"GET_WORKFLOW_ERROR","message":"调用远程服务业务异常","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +workflow-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --workflow-id DWSREALREADNOSUCHID0000000000000 --yes --format json` |
|
||||
| `read` | `aitable +workflow-list` | `-` | `2127d89817840997572427879e075d` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"LIST_WORKFLOWS_ERROR","message":"biz error","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +workflow-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --limit 10 --offset 1 --yes --format json` |
|
||||
|
||||
## AI 表格 roleId 参数疑似未被 MCP 正确读取
|
||||
|
||||
- 优先级:P1
|
||||
- 建议 owner:AI 表格 MCP role 接口
|
||||
- 现象:CLI 已传 --role-id,但 MCP 返回 roleId is required。
|
||||
- 期望:role-id/roleId 字段应被正确映射;如果 role 不存在,返回 ROLE_NOT_FOUND,而不是 required。
|
||||
- 涉及 case:3
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +role-get` | `-` | `2104a64c17840997542904838e0817` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +role-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --role-id x --yes --format json` |
|
||||
| `write` | `aitable +role-delete` | `-` | `2106d98117840998782482701e089c` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +role-delete --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `aitable +role-update` | `-` | `2132f5ca17840998794483634e08d8` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +role-update --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --role-type x --flow-type x --sub-roles '[]' --yes --format json` |
|
||||
|
||||
## AI 表格记录主文档查询在真实 record 下返回 no record/SYSTEM_ERROR
|
||||
|
||||
- 优先级:P2
|
||||
- 建议 owner:AI 表格 primary doc MCP / 后端
|
||||
- 现象:record-query 已能查到真实 recordId,但 primary-doc 查询返回 no record、type=SYSTEM_ERROR。
|
||||
- 期望:若该记录无主文档,应返回空/未创建;若 recordId 语义不匹配,应返回明确参数错误,不应是系统错误。
|
||||
- 涉及 case:2
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `read` | `aitable +base-get-primary-doc-id` | `-` | `0b5deb3217840997466255627e08ee` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +base-get-primary-doc-id --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json` |
|
||||
| `read` | `aitable +record-primary-doc-get` | `-` | `2127d89817840997528393730e079c` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-primary-doc-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json` |
|
||||
|
||||
## AI 表格无效 Base/Table/Field/Record 被包装成 SYSTEM_ERROR
|
||||
|
||||
- 优先级:P2
|
||||
- 建议 owner:AI 表格 MCP wrapper / 后端错误码
|
||||
- 现象:安全负向 ID 下,部分写接口返回 getDentryDTO returns null、type=SYSTEM_ERROR、retryable=true。
|
||||
- 期望:资源不存在应返回 INPUT_ERROR/NOT_FOUND 且 retryable=false,避免误导调用方重试。
|
||||
- 涉及 case:4
|
||||
|
||||
| 套件 | shortcut | operation | trace_id | 证据 |
|
||||
|---|---|---|---|---|
|
||||
| `write` | `aitable +field-delete` | `-` | `0bab027317840998611338768e08c8` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +field-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `aitable +field-update` | `-` | `213ee25c17840998623207342e08e2` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +field-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --config '{}' --ai-config '{}' --yes --format json` |
|
||||
| `write` | `aitable +record-delete` | `-` | `2132f5ca17840998724753149e0853` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +record-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --record-ids DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
|
||||
| `write` | `aitable +table-update` | `-` | `213ee25c17840998877246229e087f` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
|
||||
| | input | | | `/private/tmp/dws-real-test aitable +table-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --description 'DWS shortcut 真实测试描述,可删除' --record-name-key task --yes --format json` |
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,279 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Shortcut 真实测试失败逐项 review</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#151d2b;--line:#263246;--text:#dce7f7;--muted:#91a0b5;--blue:#8fd3ff;--green:#66d38a;--yellow:#e2b23c;--red:#f27272;--purple:#d3a7ff}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--text);font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Microsoft YaHei",sans-serif}
|
||||
header{padding:28px 32px 14px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#172033,#0f1420)}
|
||||
h1{margin:0 0 8px;font-size:26px}
|
||||
h2{margin:28px 0 10px;font-size:18px}
|
||||
.sub,.note,.count{color:var(--muted)}
|
||||
.wrap{padding:18px 32px 40px;max-width:1800px;margin:0 auto}
|
||||
.note{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:10px 0 18px}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:18px 0}
|
||||
.stat{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px}
|
||||
.stat .n{font-size:24px;color:var(--blue);font-weight:700}
|
||||
.stat .l{color:var(--muted);font-size:12px}
|
||||
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin:16px 0 22px}
|
||||
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||
th,td{padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
|
||||
th{background:#1b2536;color:var(--muted);font-size:12px;font-weight:600;position:sticky;top:0;z-index:1}
|
||||
tr:last-child td{border-bottom:none}
|
||||
code{font-family:"SF Mono",Menlo,Consolas,monospace;color:#c7cfdb;font-size:12px}
|
||||
.review{table-layout:fixed}
|
||||
.review th:nth-child(1),.review td:nth-child(1){width:44px}
|
||||
.review th:nth-child(2),.review td:nth-child(2){width:165px}
|
||||
.review th:nth-child(3),.review td:nth-child(3){width:70px}
|
||||
.review th:nth-child(4),.review td:nth-child(4){width:120px}
|
||||
.review th:nth-child(5),.review td:nth-child(5){width:130px}
|
||||
.review th:nth-child(8),.review td:nth-child(8){width:130px}
|
||||
.review th:nth-child(9),.review td:nth-child(9){width:180px}
|
||||
.review td{word-break:break-word}
|
||||
.num{color:var(--muted);text-align:right}
|
||||
.risk{color:var(--green)}
|
||||
.cat{color:var(--yellow)}
|
||||
.owner{color:var(--purple)}
|
||||
.evidence{color:#c7cfdb;font-size:12px}
|
||||
a{color:var(--blue)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Shortcut 真实测试失败逐项 review</h1>
|
||||
<div class="sub">由 <code>scripts/gen_shortcut_error_review.py</code> 从真实测试结果生成;目标是把每个失败项落到“应该改哪里”。</div>
|
||||
</header>
|
||||
<main class="wrap">
|
||||
<div class="note">
|
||||
Read:204 条,成功 162,失败 41,超时 0。
|
||||
Write:162 条,成功 48,失败 114,超时 0。
|
||||
判定口径:如果 fake MCP 已看到字段但真实后端仍报 required,按后端/MCP schema 映射处理;如果真实后端报资源无效/不存在,按 fixture 处理;权限类不在 CLI 中绕过。
|
||||
</div>
|
||||
|
||||
<div class="stats"><div class="stat"><div class="n">41</div><div class="l">Read 失败</div></div>
|
||||
<div class="stat"><div class="n">114</div><div class="l">Write 失败</div></div>
|
||||
<div class="stat"><div class="n">156</div><div class="l">逐项 review</div></div>
|
||||
<div class="stat"><div class="n">72</div><div class="l">测试数据/fixture</div></div>
|
||||
<div class="stat"><div class="n">22</div><div class="l">权限/应用配置</div></div>
|
||||
<div class="stat"><div class="n">33</div><div class="l">后端/MCP schema</div></div></div>
|
||||
<div class="summary-grid">
|
||||
<section><h2>按错误类型</h2><table><thead><tr><th>类型</th><th>数量</th></tr></thead><tbody><tr><td>输入/业务校验</td><td>36</td></tr>
|
||||
<tr><td>后端/MCP</td><td>33</td></tr>
|
||||
<tr><td>缺 AI 表格 fixture</td><td>31</td></tr>
|
||||
<tr><td>缺真实资源</td><td>30</td></tr>
|
||||
<tr><td>鉴权/权限</td><td>22</td></tr>
|
||||
<tr><td>缺妙记 fixture</td><td>3</td></tr>
|
||||
<tr><td>敏感/高风险暂缓</td><td>1</td></tr></tbody></table></section>
|
||||
<section><h2>按要改哪里</h2><table><thead><tr><th>要改哪里</th><th>数量</th></tr></thead><tbody><tr><td>测试数据</td><td>72</td></tr>
|
||||
<tr><td>后端/MCP schema</td><td>33</td></tr>
|
||||
<tr><td>权限/应用配置</td><td>22</td></tr>
|
||||
<tr><td>测试输入/业务校验</td><td>13</td></tr>
|
||||
<tr><td>后端业务/测试 fixture</td><td>11</td></tr>
|
||||
<tr><td>测试输入</td><td>2</td></tr>
|
||||
<tr><td>人工安全确认</td><td>1</td></tr>
|
||||
<tr><td>测试输入/shortcut 枚举</td><td>1</td></tr>
|
||||
<tr><td>测试输入/业务规则</td><td>1</td></tr></tbody></table></section>
|
||||
</div>
|
||||
|
||||
|
||||
<h2>缺真实资源复盘 <span class="count">· 可自造/可查资源处理结果</span></h2>
|
||||
<table>
|
||||
<thead><tr><th>命令/范围</th><th>处理状态</th><th>本次实际排查/造数结果</th><th>后续建议</th></tr></thead>
|
||||
<tbody><tr><td><code>chat +group-members</code></td><td><span class="cat">已补齐</span></td><td>查到真实群名 `浅曦-kida,Dennis,秋画`,runner 已改为用群名而不是 openConversationId;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>chat +messages-mget</code></td><td><span class="cat">已补齐</span></td><td>复用真实单聊消息 `msgEuOor1PmFBNlx9M06N9z1Q==`;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>chat +messages-read-status</code></td><td><span class="cat">已补齐</span></td><td>复用真实单聊会话 `cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=` 与同一 openMessageId;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>chat +messages-query-send-status</code></td><td><span class="cat">已补齐</span></td><td>复用真实发送返回的 openTaskId;真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>ding +receiver-status</code></td><td><span class="cat">已补齐</span></td><td>先只读 `ding +list` 找到已有 openDingId,再查询 receiver status;没有新发 DING,真实回归成功。</td><td>无需后续动作。</td></tr>
|
||||
<tr><td><code>sheet +list-sheets</code></td><td><span class="cat">已自造</span></td><td>创建临时在线表格 `DWS shortcut 真实测试表格 20260715`,nodeId=`mweZ92PV6O36dZbnsMZx70ylJxEKBD6p`;真实回归成功。</td><td>后续可保留为稳定 fixture,或测试结束后人工清理。</td></tr>
|
||||
<tr><td><code>todo +todo-done</code></td><td><span class="cat">已自造并修复 CLI</span></td><td>runner 会先创建当前账号自己的临时待办,再执行 `todo +todo-done`;同时修复了 todo 列表 pageSize=50 返回空、响应多层 result unwrap 不稳的问题;真实回归成功。</td><td>无需后续动作;代码已有单测覆盖 nested result。</td></tr>
|
||||
<tr><td><code>contact +by-mobile</code></td><td><span class="cat">已按用户授权补齐</span></td><td>使用用户指定手机号 `13161187007` 作为真实 fixture;runner 只在该命令上替换 mobile,不扩散到其它服务。</td><td>真实回归成功后该项将从失败列表移除;若后续要脱敏公开报告,可再加展示层脱敏。</td></tr>
|
||||
<tr><td><code>attendance +get-class / +get-group / +get-group-filtered</code></td><td><span class="cat">不建议自造</span></td><td>`attendance +search-class` 与 `+search-group --type FIXED` 均返回空;创建班次/考勤组会改组织考勤配置,属于高影响业务数据。</td><td>需要考勤后端/业务同学提供可读测试班次与考勤组 ID。</td></tr>
|
||||
<tr><td><code>chat +chat-get-by-id</code></td><td><span class="cat">暂未找到</span></td><td>该 shortcut 只接受数字 groupId;真实群列表只返回 openConversationId,没有数字群号字段。</td><td>需要 IM 后端提供可用数字 groupId,或评估是否新增 openConversationId 形态的 shortcut。</td></tr>
|
||||
<tr><td><code>chat +messages-resource-url</code></td><td><span class="cat">暂未自造</span></td><td>需要真实含 mediaId 的图片/文件/视频消息;当前文本消息无法产生 resource-id。</td><td>可在测试群发一条图片/文件消息并提取 mediaId 后补 fixture;注意会产生群消息。</td></tr>
|
||||
<tr><td><code>chat +thread-replies</code></td><td><span class="cat">暂未自造</span></td><td>需要真实话题消息 topicId;普通群消息不能替代。</td><td>需要话题群 fixture,或由 IM 同学提供当前账号可访问 topicId。</td></tr>
|
||||
<tr><td><code>aitable +dashboard-share-get</code></td><td><span class="cat">真实资源仍失败</span></td><td>已有真实 base/dashboard,但 share-get 返回 404 `Failed to get dashboard share config`;更像分享配置未开启或后端接口行为问题。</td><td>需要 AI 表格/后端确认如何创建/开启 dashboard share fixture,或修正 404 语义。</td></tr>
|
||||
<tr><td><code>write 类 delete/recall/approve/wiki move/copy 等</code></td><td><span class="cat">不自动自造</span></td><td>这些命令即便能造资源,也会涉及删除、撤回、审批通过、知识库移动/复制等高影响动作。</td><td>需要逐项授权和专门测试空间/机器人/审批单据,不建议混在批量回归里自动跑。</td></tr></tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<h2>Read 失败逐项 <span class="count">· 42 条</span></h2>
|
||||
<table class="review">
|
||||
<thead><tr>
|
||||
<th>#</th><th>命令</th><th>风险</th><th>类型</th><th>要改哪里</th><th>具体改法</th><th>验证方式</th><th>operation</th><th>trace_id</th><th>证据</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="num">1</td><td><code>aitable +base-get-primary-doc-id</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query cell doc for record 1015oH3OXy in table…</td></tr>
|
||||
<tr><td class="num">2</td><td><code>aitable +chart-share-get</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +chart-share-get`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"403","message":"Forbidden","retryable":false,"type":"AUTH_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get chart share config for chart widget-dlxFo…</td></tr>
|
||||
<tr><td class="num">3</td><td><code>aitable +dashboard-share-get</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +dashboard-share-get`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"Not Found","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get dashboard share config for dashboard KY9…</td></tr>
|
||||
<tr><td class="num">4</td><td><code>aitable +export-data</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"taskId cannot be combined with scope, format, tableId or viewId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,…</td></tr>
|
||||
<tr><td class="num">5</td><td><code>aitable +record-primary-doc-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query cell doc for record 1015oH3OXy in table…</td></tr>
|
||||
<tr><td class="num">6</td><td><code>aitable +role-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get role because roleId …</td></tr>
|
||||
<tr><td class="num">7</td><td><code>aitable +workflow-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"GET_WORKFLOW_ERROR","message":"调用远程服务业务异常","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get workflow in base 'gpG2Nd…</td></tr>
|
||||
<tr><td class="num">8</td><td><code>aitable +workflow-list</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"LIST_WORKFLOWS_ERROR","message":"biz error","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to list workflows in base 'gpG…</td></tr>
|
||||
<tr><td class="num">9</td><td><code>attendance +get-class</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>2127d89817840997588238831e0757</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_class_detail) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">10</td><td><code>attendance +get-global-setting</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +get-global-setting`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840997604718062e085e</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/query_global_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">11</td><td><code>attendance +get-group</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997620512305e08ef</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_detail) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">12</td><td><code>attendance +get-group-filtered</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840997638062468e08c7</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_filtered_detail) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">13</td><td><code>attendance +get-leave-balance</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `attendance +get-leave-balance` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2104a64c17840997652901307e081a</code></td><td class="evidence">[UNCLASSIFIED] 亲,假期类型没有余额 (operation: attendance-wukong/get_leave_balance_quota) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">14</td><td><code>attendance +list-report-columns</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +list-report-columns`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840997666188472e0756</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_report_columns) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">15</td><td><code>attendance +query-report-leave</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +query-report-leave`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840997679973065e085f</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_leave_time_by_leave_names) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">16</td><td><code>calendar +find-room</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入</span></td><td>会议室类命令需要真实 roomId 或更小会议室分组;修改 runner 先定位会议室/分组,再喂给查询命令。</td><td>用真实 roomId/分组重跑 calendar room/freebusy 命令。</td><td><code>tools/call</code></td><td><code>0bb7c36217840997694975303e0758</code></td><td class="evidence">[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">17</td><td><code>calendar +room-find</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入</span></td><td>会议室类命令需要真实 roomId 或更小会议室分组;修改 runner 先定位会议室/分组,再喂给查询命令。</td><td>用真实 roomId/分组重跑 calendar room/freebusy 命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840997709913005e0819</code></td><td class="evidence">[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 (operation: calendar/query_available_meeting_room) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">18</td><td><code>chat +category-list-conversations</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +category-list-conversations` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840997724375834e0758</code></td><td class="evidence">[UNCLASSIFIED] listConversationsByCategoryV2 error hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">19</td><td><code>chat +chat-get-by-id</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +chat-get-by-id`。</td><td><code>tools/call</code></td><td><code>2127d89817840997739165535e07bd</code></td><td class="evidence">[UNCLASSIFIED] verifyGroupId error: The group id does not exit (operation: im/get_conv_info_by_group_id) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">20</td><td><code>chat +chat-members-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840997754345760e07bd</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">21</td><td><code>chat +chat-messages</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840997767792656e085e</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">22</td><td><code>chat +messages-list</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840997797873841e0757</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">23</td><td><code>chat +messages-resource-url</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-resource-url`。</td><td><code>tools/call</code></td><td><code>2127d89817840997855423145e07dd</code></td><td class="evidence">[UNCLASSIFIED] failed to get download url for resourceId: x (operation: im/get_resource_download_url) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">24</td><td><code>chat +search-msg</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997866824643e0853</code></td><td class="evidence">[UNCLASSIFIED] 当前用户暂无消息搜索权益,无法执行本次搜索。请提示用户开通消息搜索权益后重试。 hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">25</td><td><code>chat +thread-replies</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +thread-replies`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997883504915e0853</code></td><td class="evidence">[UNCLASSIFIED] failed to decrypt openConvThreadId hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">26</td><td><code>contact +get-roster</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `contact +get-roster`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2106d98117840997920166915e087b</code></td><td class="evidence">[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/get_authorized_emp_rosterInfo) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">27</td><td><code>contact +list-roster-fields</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `contact +list-roster-fields`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>213ee25c17840997934295673e08e2</code></td><td class="evidence">[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/list_authorized_roster_fields) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">28</td><td><code>devapp +credentials-get</code></td><td><span class="risk">read</span></td><td><span class="cat">敏感/高风险暂缓</span></td><td><span class="owner">人工安全确认</span></td><td>该项涉及敏感读取或无安全负向目标,不适合自动用真实资源跑;需要在安全环境逐项人工确认。</td><td>人工确认后单独重跑 `devapp +credentials-get`,并避免在报告中泄露密钥/凭证。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">该命令会读取真实应用凭证/密钥;不能用真实 app 自动执行。当前仅用占位 ID 验证负向路径,真实成功需人工在安全环境单独确认。</td></tr>
|
||||
<tr><td class="num">29</td><td><code>drive +download</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `drive +download` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bab027317840997956923965e08c9</code></td><td class="evidence">[UNCLASSIFIED] 该文件类型不支持通过 download_file 下载。download_file 仅支持普通文件(如 PDF、Word、Excel 等),不支持钉钉在线文档/表格/脑图等在线编辑类型。如需导出在线文档内容,请使用钉钉文档导出相关接口。 (operation: drive/download_file) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">30</td><td><code>drive +list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `drive +list` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2106d98117840997968627612e087b</code></td><td class="evidence">[UNCLASSIFIED] parentId 不属于指定的 spaceId,请确认 parentId 和 spaceId 属于同一个钉盘空间。 hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">31</td><td><code>minutes +action-items</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
|
||||
<tr><td class="num">32</td><td><code>minutes +latest-minutes</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
|
||||
<tr><td class="num">33</td><td><code>minutes +minutes-search</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没搜到妙记</td></tr>
|
||||
<tr><td class="num">34</td><td><code>minutes +transcript</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
|
||||
<tr><td class="num">35</td><td><code>oa +done-approvals</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有已处理的审批记录</td></tr>
|
||||
<tr><td class="num">36</td><td><code>oa +pending</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">当前没有待我审批的任务</td></tr>
|
||||
<tr><td class="num">37</td><td><code>report +report-latest</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无日志</td></tr>
|
||||
<tr><td class="num">38</td><td><code>todo +due-today</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">今天没有到期的待办</td></tr>
|
||||
<tr><td class="num">39</td><td><code>todo +related-tasks</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有与你相关的待办(creator/executor/participant 三种角色下均为空)</td></tr>
|
||||
<tr><td class="num">40</td><td><code>wiki +node-list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2132f5ca17840998189126304e08d9</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">41</td><td><code>wiki +resolve-space</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有找到名称包含 DWS shortcut 真实测试 的知识空间</td></tr>
|
||||
<tr><td class="num">42</td><td><code>wiki +space-list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `wiki +space-list` 并比较 stdout/stderr。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">参数 --type 取值 "ALL" 不合法,允许值:orgWikiSpace, myWikiSpace</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
<h2>Write 失败逐项 <span class="count">· 114 条</span></h2>
|
||||
<table class="review">
|
||||
<thead><tr>
|
||||
<th>#</th><th>命令</th><th>风险</th><th>类型</th><th>要改哪里</th><th>具体改法</th><th>验证方式</th><th>operation</th><th>trace_id</th><th>证据</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="num">1</td><td><code>aitable +advperm-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +advperm-disable`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to set adv…</td></tr>
|
||||
<tr><td class="num">2</td><td><code>aitable +advperm-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +advperm-enable`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to set adv…</td></tr>
|
||||
<tr><td class="num">3</td><td><code>aitable +attachment-upload</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"BASE_NOT_FOUND","message":"Specified base does not exist, has been deleted, or is inaccessible","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":t…</td></tr>
|
||||
<tr><td class="num">4</td><td><code>aitable +base-copy</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":null,"message":"Invalid source baseId: DWSREALTESTNOSUCHID0000000000000","retryable":null,"type":"USER_ERROR"},"meta":{},"status":"error","success":true,"summary":"Invalid sou…</td></tr>
|
||||
<tr><td class="num">5</td><td><code>aitable +base-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +base-delete`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"52600003","message":"Data not found","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete base DWSREALTESTNOSUCHID000…</td></tr>
|
||||
<tr><td class="num">6</td><td><code>aitable +base-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"BASE_NOT_FOUND","message":"Specified base does not exist, has been deleted, or is inaccessible","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":t…</td></tr>
|
||||
<tr><td class="num">7</td><td><code>aitable +chart-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete …</td></tr>
|
||||
<tr><td class="num">8</td><td><code>aitable +chart-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">9</td><td><code>aitable +chart-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"config is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update chart because con…</td></tr>
|
||||
<tr><td class="num">10</td><td><code>aitable +dashboard-arrange</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to align d…</td></tr>
|
||||
<tr><td class="num">11</td><td><code>aitable +dashboard-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete …</td></tr>
|
||||
<tr><td class="num">12</td><td><code>aitable +dashboard-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">13</td><td><code>aitable +dashboard-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">14</td><td><code>aitable +field-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get current field info befor…</td></tr>
|
||||
<tr><td class="num">15</td><td><code>aitable +field-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to get current field info for u…</td></tr>
|
||||
<tr><td class="num">16</td><td><code>aitable +form-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete …</td></tr>
|
||||
<tr><td class="num">17</td><td><code>aitable +form-field-hide</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">18</td><td><code>aitable +form-field-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">19</td><td><code>aitable +form-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">20</td><td><code>aitable +form-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">21</td><td><code>aitable +import-data</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +import-data`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_IMPORT_ID","message":"importId not found: either invalid or expired","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"impo…</td></tr>
|
||||
<tr><td class="num">22</td><td><code>aitable +import-upload</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"无法解析 baseId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"无效的 baseId","trace_id":"0bab027317840998…</td></tr>
|
||||
<tr><td class="num">23</td><td><code>aitable +record-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete records","trace_id":"…</td></tr>
|
||||
<tr><td class="num">24</td><td><code>aitable +record-primary-doc-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"RESOLVE_DOC_ID_ERROR","message":"Failed to resolve docId from baseId","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to c…</td></tr>
|
||||
<tr><td class="num">25</td><td><code>aitable +record-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_RECORDS","message":"records must contain at least one writable record","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Fa…</td></tr>
|
||||
<tr><td class="num">26</td><td><code>aitable +record-upsert</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMETER","message":"records is required and must not be empty","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"records …</td></tr>
|
||||
<tr><td class="num">27</td><td><code>aitable +role-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to create …</td></tr>
|
||||
<tr><td class="num">28</td><td><code>aitable +role-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete role because role…</td></tr>
|
||||
<tr><td class="num">29</td><td><code>aitable +role-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to patch role because roleI…</td></tr>
|
||||
<tr><td class="num">30</td><td><code>aitable +section-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to create …</td></tr>
|
||||
<tr><td class="num">31</td><td><code>aitable +section-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete …</td></tr>
|
||||
<tr><td class="num">32</td><td><code>aitable +section-move-node</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to move no…</td></tr>
|
||||
<tr><td class="num">33</td><td><code>aitable +section-rename</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to rename …</td></tr>
|
||||
<tr><td class="num">34</td><td><code>aitable +section-reorder</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to reorder…</td></tr>
|
||||
<tr><td class="num">35</td><td><code>aitable +table-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"BASE_NOT_FOUND","message":"Specified base does not exist, has been deleted, or is inaccessible","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":t…</td></tr>
|
||||
<tr><td class="num">36</td><td><code>aitable +table-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update table DWSREALTESTNOSU…</td></tr>
|
||||
<tr><td class="num">37</td><td><code>aitable +view-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to delete …</td></tr>
|
||||
<tr><td class="num">38</td><td><code>aitable +view-duplicate</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to duplica…</td></tr>
|
||||
<tr><td class="num">39</td><td><code>aitable +view-lock</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to lock_or…</td></tr>
|
||||
<tr><td class="num">40</td><td><code>aitable +view-set-fill-color-rule</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"conditionalFormats is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to set fill col…</td></tr>
|
||||
<tr><td class="num">41</td><td><code>aitable +view-set-frozen-cols</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to set fro…</td></tr>
|
||||
<tr><td class="num">42</td><td><code>aitable +view-set-row-height</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to set cel…</td></tr>
|
||||
<tr><td class="num">43</td><td><code>aitable +view-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_BASE_ID","message":"baseId cannot be resolved to docId","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to update …</td></tr>
|
||||
<tr><td class="num">44</td><td><code>aitable +workflow-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"BASE_NOT_FOUND","message":"Cannot resolve base 'DWSREALTESTNOSUCHID0000000000000', please check if the baseId is valid","retryable":false,"type":"INPUT_ERROR"},"meta":{},"sta…</td></tr>
|
||||
<tr><td class="num">45</td><td><code>aitable +workflow-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {"data":{},"error":{"code":"BASE_NOT_FOUND","message":"Cannot resolve base 'DWSREALTESTNOSUCHID0000000000000', please check if the baseId is valid","retryable":false,"type":"INPUT_ERROR"},"meta":{},"sta…</td></tr>
|
||||
<tr><td class="num">46</td><td><code>attendance +boss-check</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>213ee25c17840998998942184e087d</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/boss_check) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">47</td><td><code>attendance +create-class</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0bab027317840999009926838e090b</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_class_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">48</td><td><code>attendance +create-group</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2106d98117840999021121258e08b8</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_group_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">49</td><td><code>attendance +import-schedule</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2104a64c17840999034845189e085e</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/generateTurnSchedule) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">50</td><td><code>attendance +save-leave-balance</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +save-leave-balance`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999045751134e079c</code></td><td class="evidence">[UNCLASSIFIED] 无权更新指定员工的假期余额 (operation: attendance-wukong/update_leave_balance) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">51</td><td><code>attendance +update-class</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999058236471e08b5</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_class_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">52</td><td><code>attendance +update-group</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999068826691e087a</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_setting) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">53</td><td><code>attendance +update-group-members</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2127d89817840999081094644e07dd</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_member) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">54</td><td><code>attendance +update-leave-type</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +update-leave-type`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999093924897e07fe</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: attendance-wukong/save_leave_type) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">55</td><td><code>calendar +respond-event</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `calendar +respond-event`。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999105062121e07db</code></td><td class="evidence">[UNCLASSIFIED] code: 300000, developerMessage: Event does not exist. (operation: calendar/respond) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">56</td><td><code>chat +category-add-conversation</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +category-add-conversation`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999117776203e08f9</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/add_conv_to_categories) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">57</td><td><code>chat +category-remove-conversation</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +category-remove-conversation`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999130466520e085e</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/remove_conv_from_categories) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">58</td><td><code>chat +chat-add-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +chat-add-bot`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999144193460e08c7</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: bot/add_robot_to_group) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">59</td><td><code>chat +chat-audit-join</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999154832733e0758</code></td><td class="evidence">[UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">60</td><td><code>chat +chat-mute-member</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999166036583e08a3</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">61</td><td><code>chat +chat-quit</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-quit` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999176728426e0779</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/quit_group) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">62</td><td><code>chat +chat-remove-bot</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +chat-remove-bot`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999189888305e08b5</code></td><td class="evidence">[UNCLASSIFIED] 无效的会话 (operation: bot/remove_robot_in_group) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">63</td><td><code>chat +chat-role-remove</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-role-remove` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999200483204e079c</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_group_role) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">64</td><td><code>chat +chat-role-remove-user</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-role-remove-user` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999211317952e0757</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_user_roles) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">65</td><td><code>chat +chat-transfer-owner</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999222318863e087a</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">66</td><td><code>chat +chat-update-icon</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-update-icon` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2104a64c17840999232478654e0819</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/update_group_icon) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">67</td><td><code>chat +chat-update-settings</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/shortcut 枚举</span></td><td>把测试输入的 setting-key 从 x 改为后端支持的 key;同时可在 shortcut flag 上补 enum,避免用户传非法 key。</td><td>改 runner 后重跑;如果补 enum,跑 shortcut 单测确认校验文案。</td><td><code>tools/call</code></td><td><code>2127d89817840999244326971e07dd</code></td><td class="evidence">[UNCLASSIFIED] unsupported setting key: x (operation: im/update_group_settings) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">68</td><td><code>chat +conversation-clear-messages</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999254816721e079b</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">69</td><td><code>chat +conversation-clear-red-point</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999265511295e085f</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">70</td><td><code>chat +conversation-hide</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999276117140e079b</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">71</td><td><code>chat +conversation-mark-read</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2127d89817840999287654280e07bd</code></td><td class="evidence">[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/mark_message_read) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">72</td><td><code>chat +conversation-mark-unread</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999298744910e0758</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">73</td><td><code>chat +conversation-mute</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999309241865e085f</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">74</td><td><code>chat +conversation-mute-at-all</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999320028068e07dd</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">75</td><td><code>chat +conversation-mute-red-envelope</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999330228283e07fe</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">76</td><td><code>chat +conversation-set-top</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999341302961e07fe</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">77</td><td><code>chat +messages-add-emoji</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2127d89817840999352941910e0756</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_emoji_reaction) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">78</td><td><code>chat +messages-add-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999362862711e08b5</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">79</td><td><code>chat +messages-batch-recall-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-batch-recall-by-bot`。</td><td><code>tools/call</code></td><td><code>2104a64c17840999373456305e0817</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/batch_recall_robot_users_msg) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">80</td><td><code>chat +messages-batch-send-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-batch-send-by-bot`。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999385748776e0757</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/batch_send_robot_msg_to_users) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">81</td><td><code>chat +messages-combine-forward</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2104a64c17840999396596163e08ee</code></td><td class="evidence">[UNCLASSIFIED] srcOpenCid无效,无法解析为cid (operation: im/combine_forward_messages) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">82</td><td><code>chat +messages-create-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务规则</span></td><td>换成后端支持的文字表情组合,或把该命令保留为业务负向;CLI 不应绕过后端限制。</td><td>用一个真实可保存的表情模板重跑。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999407422891e08cd</code></td><td class="evidence">[UNCLASSIFIED] 暂不支持保存该文字表情 (operation: im/create_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">83</td><td><code>chat +messages-forward</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840999417966407e08ee</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">84</td><td><code>chat +messages-forward-topic</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2127d89817840999428541474e07dd</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_topic) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">85</td><td><code>chat +messages-recall</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2104a64c17840999441138103e08c7</code></td><td class="evidence">[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/recall_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">86</td><td><code>chat +messages-recall-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2104a64c17840999454382709e08a3</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/recall_robot_group_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">87</td><td><code>chat +messages-remove-emoji</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999467733059e08f9</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_emoji_reaction) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">88</td><td><code>chat +messages-remove-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999478923406e087f</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">89</td><td><code>chat +messages-send-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-send-by-bot`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999494005921e08ef</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/send_robot_group_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">90</td><td><code>chat +messages-send-card</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999509255753e081a</code></td><td class="evidence">[UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">91</td><td><code>chat +messages-set-pin</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999521117991e0758</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">92</td><td><code>chat +messages-set-top</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840999532438476e0817</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/set_top_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">93</td><td><code>chat +messages-unset-pin</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999544428103e08ee</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">94</td><td><code>chat +messages-unset-top</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999554154221e08f9</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/unset_top_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">95</td><td><code>devapp +event-subscribe</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +event-subscribe`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999564022020e08c7</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有应用事件订阅权限 (operation: devapp/subscribe_dev_app_events) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">96</td><td><code>devapp +event-unsubscribe</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +event-unsubscribe`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999575698500e07db</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/unsubscribe_dev_app_events) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">97</td><td><code>devapp +permission-add</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +permission-add`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999587758457e079c</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/apply_dev_app_permissions) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">98</td><td><code>devapp +permission-remove</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +permission-remove`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999598445247e085e</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/remove_dev_app_permissions) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">99</td><td><code>devapp +robot-config</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-config`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999609828961e07db</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/set_extension_robot_config) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">100</td><td><code>devapp +robot-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-disable`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999620461113e08ee</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/disable_dev_app_robot) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">101</td><td><code>devapp +robot-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-enable`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999632641525e0758</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/enable_dev_app_robot) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">102</td><td><code>devapp +security-config</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +security-config`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999645522046e0817</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/update_dev_app_security_config) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">103</td><td><code>devapp +version-create</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999656705920e0853</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/create_dev_app_version) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">104</td><td><code>devapp +version-publish</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2127d89817840999667906017e075d</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/publish_dev_app_version) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">105</td><td><code>ding +send-by-message</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `ding +send-by-message` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999678466213e0853</code></td><td class="evidence">[UNCLASSIFIED] remindType非法,合法值:APP/SMS/PHONE (operation: im/send_ding_by_message) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">106</td><td><code>doc +comment-create-inline</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>2127d89817840999689931893e079c</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc-comment/create_inline_comment) hint: Document may have been deleted or moved</td></tr>
|
||||
<tr><td class="num">107</td><td><code>doc +template-apply</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `doc +template-apply`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999701186954e0757</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: doc/apply_doc_template) hint: Check if the resource exists or if your account has permission</td></tr>
|
||||
<tr><td class="num">108</td><td><code>minutes +record-pause</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-pause` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999713124987e0757</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">109</td><td><code>minutes +record-resume</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-resume` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999724452848e0758</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">110</td><td><code>minutes +record-stop</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-stop` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999735397508e0757</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
|
||||
<tr><td class="num">111</td><td><code>oa +approve-by</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `oa +approve-by`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没找到待审批单据:待我处理的审批里没有标题/单号包含 "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__" 的单据。</td></tr>
|
||||
<tr><td class="num">112</td><td><code>wiki +node-copy</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999769818495e0779</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc/copy_document) hint: Document may have been deleted or moved</td></tr>
|
||||
<tr><td class="num">113</td><td><code>wiki +node-move</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999780286509e07fe</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc/move_document) hint: Document may have been deleted or moved</td></tr>
|
||||
<tr><td class="num">114</td><td><code>wiki +wiki-new-doc</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `wiki +wiki-new-doc`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没找到名为 "__DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__" 的知识库;换个更完整/精确的空间名再试。</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,223 @@
|
||||
# DWS Shortcut — 方法论与进展交接文档(换会话续跑用)
|
||||
|
||||
> 目的:换新会话直接照此续跑。记录**方法论、已完成进展、如何继续、关键坑位、验证命令**。
|
||||
> 分支:`feature/shortcut`(**改动全部未提交**,commit 由用户主动决定)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话现状
|
||||
|
||||
> 2026-07-09 **去冗余(重大修订)**:复盘发现 `internal/helpers/` 早有 ~697 个 `dws <svc> <verb>` 产品命令封装了 281 个 tool;1:1 shortcut 层有 235 个 tool 与之重复。按「tool 已被 helper 封装 且 shortcut 用 CallMCP 无投影」精确删除 **213 条纯重复 shortcut**,1:1 层 511→298、总数 579→366、服务 19→16(aisearch/live/devdoc 整包移除,其 dws 命令仍由 helper 提供)。全绿+真机复验保留命令可用。**教训:建封装层前先审已有封装。**
|
||||
|
||||
|
||||
在 `internal/shortcut/` 下建成一套声明式 shortcut 体系:**366 条命令 = 298 条 1:1 封装 + 68 条真·智能编排**(1:1 层原 511,已删 213 条纯重复——helper 层早已封装同一批 tool),另有 **~60 条封装升级到 lark 输出投影保真度**、P2 高频自动沉淀闭环、深度对齐 lark 矩阵。**全绿**(build/gofmt/vet/shortcut 全量测试 `shortcuts=366 assembled=322 validated=44 failed=0`/app 全量回归 72s/真机抽验)。
|
||||
|
||||
> 2026-07-09 批33(净新增 1 条·+conflicts 的互补品):`calendar +free-slots`(找某天工作时段内的空闲时段,list_calendar_events + 合并忙碌区间 + 工作窗口求补集,默认今天 09:00-18:00/--from/--to/--in-days)。真机验证:今日 4 段空档(09:00-09:15/15min、12:00-13:30/90min、14:00-14:30/30min、16:00-17:15/75min),正是忙碌事件的精确补集。conflicts+free-slots 构成真实排期智能。总数 578→579、smart 67→68。全绿。
|
||||
|
||||
> 2026-07-09 批32b(净新增 1 条·dws 原生编排,lark 也没有):`calendar +conflicts`(检测某天日程时间冲突/双重预订,list_calendar_events + 本地两两 [start,end) 重叠检测,默认今天/--in-days)。真机验证:抓到今日 9 个日程里 2 处真实冲突(技术标评审 10:00-11:00 × AIX 共创 10:30-12:00;尖角班 14:30-15:30 × 中控项目 15:00-16:00)。证明复杂写死胡同之外,纯 MCP-tool 的本地编排仍有净新增价值。总数 577→578、smart 66→67。全绿。
|
||||
|
||||
> 2026-07-09 批32(review lark 复杂写类 + 架构边界结论,用户指定方向):fresh review lark 复杂写 shortcut(mail +send/+reply/+forward、drive +import、doc +media-insert/download、base +record-upload-attachment),交叉 dws helpers。**关键结论——架构性死胡同,非没使劲**:① `mail +send` dws 已有 1:1 `+send`(send_email)+`+draft-*`,且 **contact 无 email 字段**→无法按名解析收件人(矩阵早 skip),也无 signature/template/lint 工具;② `drive +import`/`doc +media-*`/`base +record-upload-attachment` 核心是**本地文件字节 PUT/下载落盘**——dws 里由 helper 内部 `httpPutFile`/`http PUT/GET`(drive.go/doc.go)实现、已在 1:1 层覆盖(如 `drive upload --convert`),但 **shortcut 框架 `rt.CallMCP/CallMCPData` 只编排 MCP tool、结构上做不了原始文件 I/O**,故无法在 smart 层组合。**建议**:剩余复杂写要么卡此边界、要么已被 1:1 覆盖;真要补文件类能力应在 helper/1:1 层加命令,而非 shortcut 层。本批 review、无代码改动,总数仍 577。注:本轮触及 session 限额(8:20pm 重置)+ 分类器一度不可用,Bash 受限。
|
||||
|
||||
> 2026-07-09 批31(净新增 1 条):`calendar +my-free`(我自己的忙闲,自动解析当前 userId、默认今天,复用 +free 的 freebusySlots 投影;无需像 +free 传别人姓名)。真机正向验证:返回今日/明日真实忙碌时段 {busy:[{start,end}],userId,free}。总数 576→577、smart 65→66。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批30(净新增 1 条):`contact +me`(当前用户 `get_current_user_profile` + 投影 `{name,userId,mobile,dept,org,email}`,agent 的「我是谁」;区别于 1:1 `+get-self` 吐冗长 `result[].orgEmployeeModel` raw)。真机正向验证:董鑫阳/202397/模型算法/钉钉。总数 575→576、smart 64→65。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批29(净新增便利读 3 条 + 真机抓修 1 bug):`oa +done-approvals`(审批历史 get_done_tasks)、`mail +recent-mail`(近期收件 list_mailbox_threads + 解析绑定邮箱/收件箱 folder)、`attendance +this-month`(本月打卡 query_check_record on attendance-wukong,复用 +my-attendance)。**真机抓到并修复 bug**:`+done-approvals` 原来 --limit 不传时 pageSize=0 → 后端 business error(1:1 list-executed 有默认所以正常);改为默认 pageSize=20,真机复验走空路径「没有已处理的审批记录」。+this-month 真机有效空、+recent-mail 正确报未绑定邮箱。总数 572→575、smart 61→64。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批28(净新增便利读 smart,多 agent 并行 + 手工):再建 3 条只读 smart——`oa +pending`(`list_pending_approvals` 只读列待我审批,区别于会审批的 +approve-by)、`todo +due-today`(`get_user_todos_in_current_org` + `planFinishDateStart/End` 服务端过滤今天到期,区别于 +overdue 已过期)、`calendar +tomorrow`(明天日程,复用 +today/+week 投影)。真机:+tomorrow 返回真实明日日程;+pending/+due-today 空路径正确且复用已验证 helper。3 条为 dws 原生便利读、不对应 lark gap,矩阵 42/48 不变,总数 569→572、smart 58→61。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批27(写类输出扫荡收尾,确认无更多 bug):扫 smart 里丢弃 CallMCPData 结果的 3 处——`broadcast`(per-recipient 循环、最终结构化输出,OK)、`book`(弃 add-participant 结果但最终 `get_calendar_detail` 确认 + 失败回滚,OK)、`reschedule`(弃存在性 check detail 是有意的,随后打 update 结果,OK)。**无更多 silent-success bug**。结论:**输出质量扫荡完成**,只读投影 clean、写类确认结果、honor --format。剩余仅复杂 net-new gap-buildable(mail +send/drive +import 等多步写、难安全真机验)或 commit。真机+一致性改进累计 13 条,总数 569,全绿。
|
||||
|
||||
> 2026-07-09 批26(写类 smart 输出一致性批量修):扫 smart 里用 `fmt.Print*` / 无标准输出的。修 2 条:`chat +broadcast`(`fmt.Printf` 群发摘要忽略 --format → `rt.Output({sentCount,failedCount,sent,failed})`);`wiki +wiki-new-doc`(**原创建文档后丢弃 create_file 结果、静默 return nil**,真 UX bug 拿不到新文档 id/url → 捕获并 `rt.Output({created,space,title,result})`)。写类无法真机验(会真建/发),assemble 测试确认组装正确、低风险。总数仍 569。
|
||||
|
||||
> 2026-07-09 批25(+next-event 输出一致性修复 + sweep 确认多数已 clean):sweep 探 chat +conversation-list/+category-list、aitable +base-list 等——**多数 1:1 只读命令输出已 clean**(属先前 ~60 升级覆盖),保真度工作基本到位。**修复 1 条一致性**:`calendar +next-event` 原用 `fmt.Println` 打固定文本行、**忽略 --format/--jq/--fields**,改为 `rt.Output(map{event:项目投影})`(复用 +today/+week 同款投影),真机复验 `--format json` 出结构化 `{event:{title,start,end,location,eventId}}`、`--jq '.event.title'` 可用。这是 Agent 友好性修复。同时删除死代码 `shortcutNextEventSummary` + 无用 fmt import。总数仍 569。
|
||||
|
||||
> 2026-07-09 批24(1:1 层保真度升级续):`contact +list-followings` 原 `rt.CallMCP` 吐 `{arguments,result:{models:[…]}}` 信封噪音,改为 `CallMCPData`+`listFollowingsProject`,真机复验干净 `{count:13, followings:[{openDingTalkId}]}`。总数仍 569。**判断**:真机验证 + 保真度升级已到深度边际收益区(本批仅拍平 ID 列表);1:1 层多数只读命令要么需特定参数、要么后端权限受限、要么输出已可接受。**建议优先 commit 留存 24 批成果**(10 个真机改进 + 58 smart + 保真度升级 + P2 + 全套文档),再按需推进剩余 1:1 微升级。
|
||||
|
||||
> 2026-07-09 批23(验证驱动的 1:1 层保真度升级起步):真机探 1:1 只读命令,`drive +recent` 原 `rt.CallMCP` 吐冗长 raw(logId/nextCursor 噪音 + 每项巨型 docUrl + hasMore),改为 `CallMCPData`+`recentListProject`:投影 `{count, hasMore, items:[{name,nodeType,contentType,accessTime,docUrl,nodeId}], nextCursor}`,去 logId 噪音、保留分页与链接,真机复验干净。`drive +list-spaces` 真机空(有 errorCode 包裹噪音但 result.items 为空,暂不动)。总数仍 569。**注**:1:1 层仍有数十个 list 命令可类似升级,但属边际收益、量大,建议按需/被动推进,优先 commit 留存已有成果。
|
||||
|
||||
> 2026-07-09 批22(报告综合更新,反映真机验证战役):给 `shortcut-report.md` 新增 §2.4「真机验证战役」:记录 9 批真机验证(正向验证 20+ 条、抓修 8 个真实 bug 的表格、后端受限项、resolveUser 非 bug 澄清);`shortcut-report.html` §③ 测试表补 2 行 + 一段说明。诚实反映「assemble 合成测试盲区 → 真机验证补齐」的价值。纯文档,无代码改动,总数仍 569。
|
||||
|
||||
> 2026-07-09 批21(find-record 验证 + +suggest-time 保真度升级):`aitable +find-record` 真机正常(返回真实记录;cells 按字段 ID 键值、内含附件对象,天然复杂,clean 投影需 field-id→name 解析属更大改造,暂留)。**升级 1 条**:`calendar +suggest-time` 原 `rt.CallMCP` 吐 `result.recommendEventTimes[]` 且 `timeConflictAttendees:[null]` 噪音,改为 `CallMCPData`+`suggestTimeSlots`+`Output`:拍平 result、丢弃 null 冲突项,真机复验干净 `{suggestions:[{start,end}]}`(有真实冲突时才带 conflicts)。总数仍 569。**说明**:真机验证扫荡已进入边际收益递减区(明显 raw-verbose 的 wart 基本清完),剩余多为 minutes org-gated、写类、或输出已可接受。列出 12 条只读仍用 raw `rt.CallMCP` 的 smart(多为 minutes org-gated 或已验证 clean)。**升级 1 条**:`calendar +today` 原直吐 17 字段冗长事件(含完整 attendees 数组),改为 `CallMCPData`+复用 `+week` 的 `shortcutNextEventList/Start` 投影,真机复验干净输出 `{events:[{title,start,end,location,eventId}]}`(与 +week 一致 + location)。总数仍 569。剩余 raw-CallMCP 只读 smart:action-items/latest-minutes/transcript(minutes org-gated 无法真机验)、org/report-latest(已验 clean)、find-record/suggest-time/by-mobile/lookup/team(待验或权限受限)。
|
||||
|
||||
> 2026-07-09 批19(日历只读 smart 验证 + +free 保真度升级):`calendar +next-event` 真机正常(可读摘要「下一个日程:致拓 AI FDE 经验分享…」,但**忽略 --format json 只吐文本**,已知小瑕疵未改)。**升级 1 条**:`calendar +free` 终结步原 `rt.CallMCP` 直吐冗长 `result[].scheduleItems[].{start,end}.dateTime` 嵌套,改为 `CallMCPData`+`freebusySlots`+`Output`,真机复验干净输出 `{who,userId,free,busy:[{start,end}]}`(董鑫阳 2026-07-10 忙 4 段)。总数仍 569。
|
||||
|
||||
> 2026-07-09 批18(真机验证续 + +group-members 保真度升级):**验证正常**:`contact +org`(董鑫阳→模型算法/17人,3步链)、`drive +find-file`(干净投影 {dentryId,fileSize,name,type})。**后端受限(非 bug)**:`contact +team`(列部门成员 `PAT_MEDIUM_RISK_NO_PERMISSION`)、`chat +search-msg`(org 未开 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)。**升级 1 条**:`chat +group-members` 终结步原用 `rt.CallMCP`(直吐原始冗长 `result.list[]` + memberAvatarMediaId + arguments/errorCode 噪音),改为 `CallMCPData`+`groupMemberProject`+`Output`,真机复验干净输出 `{count, members:[{name,nick,role,openDingtalkId}]}`(刘力/怒龙/群主…)。总数仍 569。
|
||||
|
||||
> 2026-07-09 批17(修复批16 发现的 +at-me 投影):`chat +at-me` 原来因 `atMeMessageItems` 不认识真实两层嵌套 `result.conversationMessagesList[].messages[]` → 命中 fallback、直接吐原始结构。真机 dump 出真实结构(group 有 title/openConversationId/messages;message 有 sender/content/createTime/openConversationId),新增 `atMeFlattenGroups` 把各会话组拍平成单一消息列表、并把组的会话 title 下沉到每条消息。真机复验:43 条消息干净投影为 `{conversation,sender,text,time}`(如 conversation:"AI全栈"、sender:"龙衔")。总数仍 569。
|
||||
|
||||
> 2026-07-09 批16(只读 smart 真机验证扫荡 + 质量修复):真机跑一批时间/自身类只读 smart。**验证正常**:`calendar +today`(真实日程+参会人)、`calendar +week`(干净投影)、`todo +overdue`(空)、`attendance +my-attendance`(空)、`report +report-latest`("暂无日志"空路径)、`oa +my-initiated`(真实审批数据)。**修复 1 个输出 wart**:`chat +unread-chats` 每行都吐 `unread: null`——因 `unread_message_conversation_list` 根本不返回每会话未读数(在列表里即代表未读),改为「仅当 gateway 真返回未读数时才带 unread 字段」,真机复验输出已干净 `{conversationId,name}`。**已知待优化(未改)**:`chat +at-me` 返回 `result.conversationMessagesList[].messages[]` 冗长嵌套原始结构、未拍平成干净消息列表(功能正常,投影可再优化)。总数仍 569。
|
||||
|
||||
> 2026-07-09 批15(质量修复 + resolveUser 排查,真机):**修复** `contact +dept-members` 消歧消息 `<red>` 标记泄漏——复用 `stripHighlightTags`(resolve_dept.go)在 name 提取处剥离,真机复验消息已干净("开放平台(666202009)、技术平台-开放平台研发(1085781688)…")。注:`dept_members.go` 本身容器解析(含 deptList)+数值 deptId 早已健壮,仅 name markup 未剥。**排查澄清(非 bug)**:`resolveUser` 对 `董鑫阳` 真机端到端正常(userId 202397、部门 模型算法);但对 `秋画` 这类联系人 `search_contact_by_key_word` 返回 name/userId 全 null(仅 openDingTalkId),resolveUser 正确报「没找到」而非瞎猜——这是钉钉数据模型现实(外部/受限联系人无 userId),非代码 bug。**已知限制**:按名解析仅对「搜索能返回 userId 的组织内成员」有效。总数仍 569。
|
||||
|
||||
> 2026-07-09 批14(真机验证续,需具体 ID 的只读 shortcut):**正向验证过**:`chat +my-groups`(98 真实群+投影)、`aitable +base-list`/`+list-tables`(真实 base/table)、`aitable +resolve-table`(单命中 通用→99dV75A、多候选消歧,容器 key `tables` 正确,无 deptList-class bug)。**后端权限受限、无法正向验证(非代码 bug)**:`chat +chat-messages`(`PAT_MEDIUM_RISK_NO_PERMISSION`,读会话消息需更高权限)、`minutes +*`(该 org 未开启 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)。结论:可验证的 read/resolve shortcut 全部投影正确,仅批13 的 resolve-dept 有真 bug 已修。
|
||||
|
||||
> 2026-07-09 批13(真机验证 + bug 修复,登录态 corp「钉钉」):用登录态把批9-12 只读 shortcut 打真实后端。**正向验证过**:`doc +find-doc`(10 真实文档、投影干净)、`aitable +resolve-base`(多候选真实 baseId)、`mail +find-mail-user`(命中真实用户+邮箱)、`contact +resolve-dept`(修复后返回真实候选)。**真机抓到并修复 1 个真 bug**:`contact +resolve-dept` 原来对任何真实部门名都返回「未找到」——真实 `search_dept_by_keyword` 响应容器 key 是 **`deptList`**(agent 的探测清单漏了),且 `deptName` 带 `<red>…</red>` 高亮标记、`deptId` 是数值。已修:容器加 `deptList`、`stripHighlightTags` 去标记、deptId 数值 coerce 成串(`resolve_dept.go`),真机复验通过(开放平台→666202009、财务→846624121,名称干净)。**已知遗留(未改)**:`contact +dept-members` 的消歧提示消息里 `<red>` 标记未剥离(仅 cosmetic,功能正常)。总数仍 569,本批未加新命令。
|
||||
|
||||
> 2026-07-09 批12(多 agent 并行,dws 原生 resolver 层):再建 3 条「按名解析 ID」智能 shortcut——`wiki +resolve-space`(search_wikiSpaces 名→spaceId)、`aitable +resolve-table`(get_tables 在 Base 内本地名→tableId)、`contact +resolve-dept`(search_dept_by_keyword 名→deptId,**已修数值 ID 兼容**:deptId 为 JSON number 时 coerce 成串,非 string-only)。均 0/1/多候选消歧,对标 resolveUser 各资源版。这 3 条不对应具体 lark gap(是 dws 原生便利层),故 gap-buildable/covered-smart 矩阵计数不变(42/48),仅总数 566→569、smart 55→58。文档全量同步。全绿。
|
||||
|
||||
> 2026-07-09 批11(多 agent 并行):再建 3 条智能 shortcut——`aitable +resolve-base`(search_bases 按名解析 baseId + 0/1/多候选消歧)、`chat +chat-messages`(群/单聊会话消息 list_conversation_message_v2 / list_individual_chat_message,ExactlyOne 互斥 + 投影)、`mail +find-mail-user`(search_mail_users 按名搜企业邮箱联系人 + 投影)。文档全量同步 566/505/55(gap-buildable 49→42、covered-smart→48)。全绿。注:本批 app 回归首跑因并发负载 flaky FAIL 一次(80s),连跑 2 次稳定 PASS(71s)——非本次改动导致。
|
||||
|
||||
> 2026-07-09 批10(多 agent 并行):再建 3 条智能 shortcut——`chat +thread-replies`(list_topic_replies 拉话题回复 + sender/text/time 投影)、`todo +related-tasks`(get_user_todos_in_current_org 三角色 creator+executor+participant 并集 + taskId 去重 + 投影)、`doc +find-doc`(search_documents 关键词搜文档 + title/url/type/token 投影)。均以 helper 为 ground truth、0 编造。文档全量同步到 563/503/52(report.md/html、lark-alignment.md gap-buildable 49→44、covered-smart→46、comparison.html 重生成)。全量测试 + app 回归全绿。
|
||||
|
||||
> 2026-07-09 续跑增量(批9·手工):新建 3 条智能 shortcut——`minutes +detail`(单命令聚合一条听记 basic/summary/keywords/transcript/todos、partial-failure 容错)、`minutes +replace-batch`(多组 `原文=>替换` 批量替换、去重校验+逐组聚合)、`aitable +record-share-links`(>20 条记录分享链接:去重+分片≤20/批+跨 `aitable-helper` server fanout+合并)。均以 helper 为 ground truth。**同步刷新全部文档到 560/501/49**:`shortcut-report.md`、`shortcut-report.html`、`shortcut-lark-alignment.md`(gap-buildable 49→46、covered-smart 41→44)、重生成 `shortcut-comparison.html`。全量测试 + app 回归全绿。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
- 对齐基准:`/Users/dennis/Projects/larksuite/cli`(lark-cli 的 `shortcuts/` 框架,飞书 REST API)。
|
||||
- dws 执行底座:**钉钉 MCP**(粗粒度:一个 tool = 一个完整操作)。
|
||||
- 目标:把 lark 的 shortcut 能力**深度对齐每一个**到 dws,并补钉钉侧系统性能力。
|
||||
- 关键认知:lark 的"组合性"多源于飞书 API 细粒度(先查 token→id→再操作);钉钉 MCP 粗粒度,**lark 的多步在钉钉大量塌缩成 1:1(已被封装层覆盖)**。真正需要"编排"的是「按名解析 ID + 多工具串联 + 跨服务」——这些做成了 smart 层。
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构与关键文件
|
||||
|
||||
```
|
||||
internal/shortcut/
|
||||
types.go # Shortcut / Flag / Risk 声明结构
|
||||
runner.go # RuntimeContext + mount(编译成cobra) + CallMCP/CallMCPData/Output + 校验/dry-run/风险确认
|
||||
validate.go # 跨字段校验 helper:MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/RequireAll
|
||||
register.go # Register() / Commands() / All()
|
||||
shortcut_test.go# 框架单测
|
||||
builtin/
|
||||
builtin.go # blank-import 所有服务包 + smart 包;Commands() 汇总
|
||||
coverage_test.go # ★全量测试:TestAllShortcutsAssemble / TestAllToolLiteralsAreReal / TestAllHaveIntent / TestNoDuplicateCommands
|
||||
<service>/ # 19 个服务包:contact/chat/calendar/todo/doc/drive/mail/wiki/minutes/oa/report/attendance/aitable/sheet/devapp/ding/aisearch/live/devdoc
|
||||
<service>.go # 该服务的 1:1 封装 shortcut(var + init(){shortcut.Register(...)})
|
||||
smart/ # ★真·智能层(多步/编排/按名解析/跨服务)
|
||||
resolve.go # resolveUser(rt,name) 名→userId+消歧;contactUser{userID,name};extractUsers/userLabels
|
||||
dm.go lookup.go assign.go book.go free.go ... # 每条一个文件
|
||||
usage/ # P2 埋点:recorder.go(记形状不记值) stats.go command.go(dws shortcut list/stats/suggest/add)
|
||||
userdef/ # P2 自定义 shortcut YAML 运行时加载 loader.go
|
||||
|
||||
internal/app/legacy.go # 接线点:newLegacyPublicCommands 里 append builtin.Commands() + userdef.Load()
|
||||
internal/app/root.go # 装配 recordingToolCaller(埋点) + dws shortcut 命令
|
||||
internal/helpers/*.go # ★Ground truth:钉钉真实 MCP tool 名 + 参数(callMCPTool("tool",{...}))
|
||||
|
||||
docs/
|
||||
shortcut-plan.md # 总规划
|
||||
shortcut-p2-design.md # P2 自动沉淀设计
|
||||
shortcut-report.md / .html # 综合报告 + GSB
|
||||
shortcut-comparison.html # 逐条三方对照(dws vs lark vs 原生MCP)
|
||||
shortcut-lark-alignment.md # ★深度对齐矩阵(lark 361条逐条分析, 49 gap-buildable)
|
||||
shortcut-handoff.md # 本文件
|
||||
scripts/gen_shortcut_comparison.py # 生成三方对照 HTML
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 框架契约(写新 shortcut 必读)
|
||||
|
||||
一个 shortcut = 包级 `var X = shortcut.Shortcut{...}` + `func init(){ shortcut.Register(X) }`。
|
||||
|
||||
```go
|
||||
var SearchUser = shortcut.Shortcut{
|
||||
Service: "contact", // 顶层命令
|
||||
Command: "+search-user", // + 前缀,kebab-case
|
||||
Product: "contact", // MCP server id(默认=Service;注意跨 server,见坑位)
|
||||
Description: "...", // 一行
|
||||
Intent: "自然语言:做什么/何时用/副作用", // 每条必填(TestAllHaveIntent 强制)
|
||||
Risk: shortcut.RiskRead, // Read / Write / HighWrite(删除等,框架二次确认)
|
||||
Flags: []shortcut.Flag{{Name:"query", Type:shortcut.FlagString, Required:true, Desc:"...", Enum:[]string{...}}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error { return rt.RequireAll("query") }, // 可选
|
||||
Execute: func(rt *shortcut.RuntimeContext) error { ... },
|
||||
}
|
||||
```
|
||||
|
||||
RuntimeContext 方法(`internal/shortcut/runner.go`/`validate.go`):
|
||||
- 读参数:`rt.Str/Bool/Int/StrSlice(name)`、`rt.Changed(name)`
|
||||
- **调 MCP 并打印**(终结步,1:1 封装用):`rt.CallMCP(tool, params) error`(用自身 Product)
|
||||
- **调 MCP 拿数据**(多步/投影用,不打印,可跨 server):`rt.CallMCPData(product, tool, params) (map[string]any, error)`
|
||||
- **投影输出**:`rt.Output(payload) error`(吃 --format/--jq/--fields)
|
||||
- 校验:`rt.MutuallyExclusive/AtLeastOne/ExactlyOne(flags...)`、`rt.RangeInt(flag,min,max)`、`rt.RequireAll(flags...)`
|
||||
- smart 复用:`resolveUser(rt, name) (contactUser, error)`(名→userId+消歧,在 smart/resolve.go)
|
||||
|
||||
对标 lark:`CallMCPData`≈`CallAPITyped`;`resolveUser`≈`ResolveOpenIDsTyped`;`rt.Output`≈`OutFormat`;`Validate helper`≈lark 的 MutuallyExclusive/AtLeastOne。
|
||||
|
||||
---
|
||||
|
||||
## 4. 方法论(怎么高效批量建,屡试不爽)
|
||||
|
||||
**核心:多 agent workflow 并行 + helper 为 ground truth + 严格 skip + build/test 门禁。**
|
||||
|
||||
1. **每个 shortcut/服务一个 agent**,并行(`parallel(...)`)。
|
||||
2. **Ground truth 铁律**:tool 名和参数 key **只能逐字取自 `internal/helpers/<svc>.go` 的真实 `callMCPTool("tool",{params})` 调用点**,严禁编造。agent 必须先 Read+grep helper。
|
||||
3. **宁缺勿错**:拿不准的 tool/参数/结构 → **skip 并说明**,不瞎写(已多次证明 agent 会正确 skip,如 mail 无 email 字段)。
|
||||
4. **响应字段防御式解析**:返回结构无契约保证 → 多候选 key 探测(result/data/list/items + 字段别名),不硬编码。
|
||||
5. **不同 agent 写不同文件**(服务包 vs smart 包,或不同 service 文件)→ 无写冲突;**禁止 agent 改 builtin.go**(我事后统一维护 blank import)。
|
||||
6. **落地后统一**:`gofmt -w` → `go build ./...` → shortcut 全量测试 → 命名冲突用 rename 修(如 smart 的 `+approve` 撞 1:1 层 → 改 `+approve-by`)。
|
||||
7. **周期性 app 全量回归**(改多个服务文件后):`go test ./internal/app/...`(~73s)验证接线。
|
||||
|
||||
workflow 脚本模板见任意 `~/.claude/.../workflows/scripts/build-smart-*.js` 或 `upgrade-fidelity-*.js`(每次 Workflow 调用都存了盘,可 `{scriptPath}` 复用/改)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 已完成进展
|
||||
|
||||
### 5.1 覆盖层(511 条 1:1 封装 / 19 服务)
|
||||
chat89 aitable86 mail43 attendance36 doc33 minutes31 devapp30 sheet29 calendar24 drive24 oa20 todo18 wiki15 contact14 report7 ding7 aisearch3 live1 devdoc1。每条带自然语言 Intent。
|
||||
|
||||
### 5.2 智能层(~46 条 `internal/shortcut/smart/`)
|
||||
按名操作人/群、多步编排+失败回滚、时间/自身智能、跨服务、钉钉原生编排。已建(举例):
|
||||
`chat +dm/+send-to-group/+broadcast/+group-members/+at-me/+search-msg/+unread-chats`、`contact +lookup/+org/+team/+by-mobile/+dept-members`、`calendar +book(回滚)/+free/+today/+week/+next-event/+invite/+suggest-time/+reschedule/+cancel-event/+respond-event/+find-room`、`todo +assign/+assign-multi/+overdue/+todo-done/+remind/+created-todos`、`minutes +latest-minutes/+action-items/+transcript/+minutes-search/+detail/+replace-batch`、`oa +approve-by/+my-initiated`、`attendance +my-attendance`、`report +report-latest`、`aitable +find-record/+list-tables`、`doc +share-doc/+doc-append`、`wiki +wiki-new-doc`、`drive +find-file`、`mail +search-mail/+unread-mail`。
|
||||
|
||||
### 5.3 框架系统性能力(对齐 lark)
|
||||
`resolveUser`、`CallMCPData`、`rt.Output`、`Validate×5`。
|
||||
|
||||
### 5.4 保真度升级(~64 条封装:CallMCP→CallMCPData+投影+Output,对齐 lark 96% 输出投影)
|
||||
覆盖 contact/chat/calendar/todo/doc/drive/mail/wiki/aitable/oa/devapp/attendance/minutes/report/sheet 等服务的列表类命令。
|
||||
|
||||
### 5.5 P2 高频自动沉淀(差异化,lark 无)
|
||||
埋点(记形状不记值,默认关/opt-in DWS_USAGE_TRACKING=1) → `dws shortcut stats/suggest` → `dws shortcut add` 写 `~/.dws/shortcuts/*.yaml` → 运行时 `userdef.Load()` 编译注册。**闭环端到端跑通。**
|
||||
|
||||
### 5.6 深度对齐矩阵
|
||||
`docs/shortcut-lark-alignment.md`:逐条分析 lark 361 条 → covered-1to1 144 / no-dingtalk-tool 127 / **gap-buildable 49** / covered-smart 41。
|
||||
|
||||
---
|
||||
|
||||
## 6. 如何继续(下一步 backlog)
|
||||
|
||||
1. **保真度升级剩余列表命令**(还有部分服务的 list 命令仍是裸 CallMCP):起 `upgrade-fidelity-N` workflow,每服务 agent 挑 1-2 个未升级(`grep 'rt.CallMCP('`)的列表读命令,改成 CallMCPData+投影+Output。范式见 `contact.go` 的 `searchUserProject`/`listRolesProject`。
|
||||
2. **补剩余 gap-buildable smart shortcut**(矩阵里 49 个,已建 ~20+):起 `build-smart-N` workflow(smart 包,不碰服务包)。剩余偏复杂(sheets/base 操作、消息富化、分片下载),谨慎、允许 skip。
|
||||
3. **每批**:gofmt→build→shortcut 测试→(改多文件后)app 回归→更新 `docs/shortcut-report.md`。
|
||||
4. **收尾**:把 `docs/shortcut-report.md/html` 的计数刷新到最终(部分 §2 测试数字可能还停在旧值 511/456,实际 ~557/~500),重跑 `python3 scripts/gen_shortcut_comparison.py`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键坑位(务必注意)
|
||||
|
||||
- **跨 server 路由**:有些 tool 不在本服务 server。已知:contact 花名册 tool 走 `hrmregister`;chat 部分 tool 走 `im`/`bot`;`query_check_record` 走 `attendance-wukong`(不是 attendance);wiki `create_file` 走 `doc` server。→ 这类必须用 `rt.CallMCPData("<真实server>", ...)`,不能用 `rt.CallMCP`(它按 shortcut.Product 路由会打错 server)。判断依据:helper 里是 `callMCPToolOnServer("<server>", ...)`。
|
||||
- **命名冲突**:smart 命令别撞 1:1 层(如 `+approve`→用 `+approve-by`,`+freebusy`→用 `+free`)。`TestNoDuplicateCommands` 会抓。
|
||||
- **参数别名**:aitable 查询关键词是 `keyword`(不是 query);todo 建待办用嵌套 `PersonalTodoCreateVO`;日程 create/update 时间是 ISO 字符串,而 list/busy 是毫秒——一切以 helper 调用点为准。
|
||||
- **中文 const tool 名**:minutes 录音 tool 是中文 `"执行听记指令-发起AI听记录音"`(const `listeningNoteCmdTool`)——真实,别当编造。
|
||||
- **测试真机验证**:token 有时效(`dws auth status` 看 expires),过期会报错非代码问题。真机跑命令时第一次有 catalog 发现 banner(stderr),结果 JSON 在后面,别用 `head` 截断。
|
||||
- **工具标签格式**(给 AI:本会话我多次误用错标签导致工具调用失败——务必用正确的 function-call 格式)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 验证命令速查
|
||||
|
||||
```bash
|
||||
cd /Users/dennis/Projects/dingtalk-workspace/dingtalk-workspace-cli
|
||||
go build ./... # 编译
|
||||
gofmt -l internal/shortcut/ # 格式(应空)
|
||||
go test ./internal/shortcut/... # shortcut 全量(含 assemble/tool-real/intent/no-dup)
|
||||
go test ./internal/app/... # app 回归(~73s,改服务文件后必跑)
|
||||
go test ./internal/shortcut/builtin/ -run TestAllShortcutsAssemble -v 2>&1 | grep shortcuts= # 看总数
|
||||
|
||||
# 真机(需登录 dws auth login)
|
||||
DWS_USAGE_TRACKING=0 dws contact +lookup --name <真实姓名> # 智能多步示范
|
||||
DWS_USAGE_TRACKING=0 dws calendar +today # 时间智能
|
||||
DWS_USAGE_TRACKING=0 dws contact +search-user --query <名> # 投影输出示范
|
||||
```
|
||||
|
||||
测试口径:`TestAllShortcutsAssemble` = 假 Caller 拦截,给每条命令(含写/删)喂合成参数、走完解析→校验→组装 MCP 调用、断言 tool 真实无 panic(零副作用全量验证)。`TestAllToolLiteralsAreReal` = 所有 CallMCP tool 名比对 helper ground truth 防编造。
|
||||
|
||||
---
|
||||
|
||||
## 9. 未提交提醒
|
||||
|
||||
所有工作在 `feature/shortcut`,**未 commit**。建议尽快分语义化 commit 留存(框架 / 511封装 / smart层 / 保真度升级 / P2 / 文档)。
|
||||
@@ -0,0 +1,187 @@
|
||||
# lark-cli Shortcut 深度对齐矩阵
|
||||
|
||||
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
|
||||
|
||||
## 2026-07-13 最新源码复核
|
||||
|
||||
对比基线:
|
||||
|
||||
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
|
||||
- lark-cli:`main@e96c4fa5`
|
||||
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
|
||||
|
||||
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
|
||||
|
||||
### 当前命令面快照
|
||||
|
||||
| 指标 | 数量 | 说明 |
|
||||
|---|---:|---|
|
||||
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
|
||||
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
|
||||
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
|
||||
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
|
||||
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
|
||||
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
|
||||
|
||||
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
|
||||
|
||||
| 产品映射 | DWS | lark | 同名 |
|
||||
|---|---:|---:|---:|
|
||||
| aitable ↔ base | 82 | 87 | 31 |
|
||||
| calendar ↔ calendar | 23 | 10 | 3 |
|
||||
| chat ↔ im | 89 | 21 | 2 |
|
||||
| contact ↔ contact | 16 | 2 | 1 |
|
||||
| devapp ↔ apps | 30 | 63 | 3 |
|
||||
| doc ↔ doc | 19 | 14 | 1 |
|
||||
| drive ↔ drive | 9 | 26 | 3 |
|
||||
| mail ↔ mail | 10 | 21 | 0 |
|
||||
| minutes ↔ minutes | 13 | 9 | 1 |
|
||||
| sheet ↔ sheets | 2 | 42 | 0 |
|
||||
| todo ↔ task | 13 | 17 | 2 |
|
||||
| wiki ↔ wiki | 7 | 12 | 3 |
|
||||
|
||||
### 最新优先差距
|
||||
|
||||
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
|
||||
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
|
||||
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
|
||||
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
|
||||
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
|
||||
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
|
||||
|
||||
### 不建议机械追平
|
||||
|
||||
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
|
||||
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
|
||||
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
|
||||
|
||||
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
|
||||
|
||||
## 汇总(361 条 lark shortcut)
|
||||
|
||||
| dws_status | 数量 | 含义 |
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **42** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`、task `+related-tasks` |
|
||||
| covered-smart | 48 | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 7 → 剩 42,按服务)
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(6)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` | read | dws 有 list-my-groups/list-all-conversations 原子 tool,但无 types 枚举+bot剥p2p降级、无 exclude-muted 客户端过滤、无字段投影 |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
|
||||
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
|
||||
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
|
||||
|
||||
### calendar → calendar(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
|
||||
|
||||
### doc (docs) → doc(2)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
|
||||
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
|
||||
|
||||
### drive → drive(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
|
||||
|
||||
### mail → mail(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
|
||||
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
|
||||
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
|
||||
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
|
||||
|
||||
### wiki → wiki(1)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
|
||||
|
||||
### minutes → minutes(4)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
|
||||
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
|
||||
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
|
||||
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
|
||||
|
||||
### base → aitable(10)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
|
||||
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
|
||||
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
|
||||
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
|
||||
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
|
||||
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
|
||||
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
|
||||
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
|
||||
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
|
||||
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
|
||||
|
||||
### sheets → sheet(14)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
|
||||
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
|
||||
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
|
||||
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
|
||||
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
|
||||
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
|
||||
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
|
||||
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
|
||||
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
|
||||
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
|
||||
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
|
||||
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
|
||||
|
||||
### apps → devapp(3)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
|
||||
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
|
||||
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
|
||||
|
||||
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
|
||||
|
||||
- **im**: +chat-members-list +messages-send +threads-messages-list
|
||||
- **task**: +complete +assign +get-my-tasks +get-related-tasks
|
||||
- **contact**: +search-user
|
||||
- **calendar**: +agenda +create +update +freebusy +suggestion
|
||||
- **doc (docs)**: +history-revert
|
||||
- **drive**: +upload +search +inspect
|
||||
- **mail**: +triage
|
||||
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
|
||||
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
|
||||
@@ -0,0 +1,204 @@
|
||||
# DWS Shortcut P2 详细设计 — 高频场景自动沉淀为自定义 Shortcut
|
||||
|
||||
> 前置:P1 已交付静态声明式 shortcut 框架(`internal/shortcut/`),见 `docs/shortcut-plan.md`。
|
||||
> P2 目标:**观察用户高频使用 → 主动建议 → 一键沉淀为可复用的自定义 shortcut**,
|
||||
> 让 CLI 越用越顺手。这是 dws 相对 larksuite/cli 的差异化能力。
|
||||
|
||||
## 0. 体验闭环(一句话)
|
||||
|
||||
```
|
||||
用户反复敲 dws chat send_message --json '{"open_conversation_id":"cid_x","text":"..."}'
|
||||
│ (每次执行被静默记录到 ~/.dws/usage.jsonl)
|
||||
▼
|
||||
第 N 次后,dws 主动提示:
|
||||
💡 你已 12 次向「项目群」发消息,是否沉淀为 `dws chat +notify-team`?[y/N]
|
||||
│ y
|
||||
▼
|
||||
写入 ~/.dws/shortcuts/chat.notify-team.yaml
|
||||
▼
|
||||
之后:dws chat +notify-team --text "发布完成" ← 参数从一堆 JSON 收敛成一个 flag
|
||||
```
|
||||
|
||||
## 1. 总体架构
|
||||
|
||||
复用 P1 的 `Shortcut` 模型作为「编译目标」,新增四个部件:
|
||||
|
||||
| 部件 | 位置(建议) | 职责 |
|
||||
|------|-------------|------|
|
||||
| Usage 埋点 | `internal/shortcut/usage/recorder.go` | 每次 MCP 调用后追加一条 usage 记录 |
|
||||
| 模式挖掘 | `internal/shortcut/usage/miner.go` | 聚合 usage → 高频候选 + 打分 |
|
||||
| 主动提示 | `internal/shortcut/usage/nudge.go` | 命中候选时在命令收尾处提示 |
|
||||
| YAML 加载 | `internal/shortcut/userdef/loader.go` | 扫描 `~/.dws/shortcuts/*.yaml` → 编译成 `Shortcut` → 注册 |
|
||||
| 管理命令 | `internal/shortcut/usage`(cobra) | `dws shortcut list/suggest/add/rm/stats` |
|
||||
|
||||
数据流:
|
||||
|
||||
```
|
||||
CallMCP ──► recorder.Append(usage) [写侧,热路径,必须极轻]
|
||||
┌─► miner.TopCandidates() [读侧,suggest 时才算]
|
||||
~/.dws/usage.jsonl ─────────────────┤
|
||||
└─► nudge (命令收尾抽样触发)
|
||||
~/.dws/shortcuts/*.yaml ──► loader.Compile() ──► shortcut.Register() [启动时]
|
||||
```
|
||||
|
||||
## 2. Usage 埋点
|
||||
|
||||
### 2.1 采集点(choke point)
|
||||
|
||||
**首选**:装饰 `executor.Runner` / `edition.ToolCaller`。`internal/app/tool_caller_adapter.go`
|
||||
的 `CallTool(ctx, productID, toolName, args)` 是**所有 MCP 调用的唯一必经点**,天然拿到
|
||||
`(product, tool, args)` 三元组。用装饰器包一层即可,零侵入命令层:
|
||||
|
||||
```go
|
||||
type recordingCaller struct{ inner edition.ToolCaller }
|
||||
func (r recordingCaller) CallTool(ctx, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
res, err := r.inner.CallTool(ctx, product, tool, args)
|
||||
usage.Append(product, tool, args, err == nil) // 异步/带 recover,绝不影响主流程
|
||||
return res, err
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:P1 的 shortcut 也走这条 `CallMCP → helpers → deps.Caller`,所以内建 shortcut 的
|
||||
> 使用同样会被记录,可用于「哪些内建 shortcut 最受欢迎」的洞察。
|
||||
|
||||
### 2.2 记录内容(**隐私优先:记形状不记值**)
|
||||
|
||||
`~/.dws/usage.jsonl`,每行一条:
|
||||
|
||||
```json
|
||||
{
|
||||
"ts": "2026-07-08T10:12:33+08:00",
|
||||
"product": "chat",
|
||||
"tool": "send_message",
|
||||
"arg_keys": ["open_conversation_id", "text"],
|
||||
"const_args": {"open_conversation_id": "cid_x"},
|
||||
"ok": true
|
||||
}
|
||||
```
|
||||
|
||||
- `arg_keys`:参数键集合(排序),用于识别「同一种调用形状」。
|
||||
- `const_args`:**仅收敛出的「疑似固定值」**(见 §3 挖掘时判定),写入时不保证脱敏,
|
||||
因此需要一层白名单/黑名单:`text/content/body/message` 等自由文本字段**永不入库**,
|
||||
只保留看起来像 ID/枚举的短值(长度阈值 + 无空格 + 非多行)。
|
||||
- 绝不记录:token、手机号、邮箱、文件内容、消息正文。用 `internal/logging/redact.go`
|
||||
已有的脱敏能力复核。
|
||||
|
||||
### 2.3 热路径约束
|
||||
|
||||
- 追加写用 `O_APPEND`,单行 < 1KB;失败静默(`recover` + debug 日志),**绝不阻断命令**。
|
||||
- 文件滚动:超过 N 行(如 5000)或 M 天,截断/归档,避免无限增长。
|
||||
- 开关:默认关闭(opt-in),环境变量 `DWS_USAGE_TRACKING=1` 开启(本地遥测即便只记形状也不应未经用户同意默认开启)。
|
||||
首次启用时在 `dws` 首跑给一次性告知(尊重知情)。
|
||||
|
||||
## 3. 模式挖掘
|
||||
|
||||
`dws shortcut suggest` 触发(也被 nudge 复用)。算法:
|
||||
|
||||
1. 读 usage.jsonl,按 `(product, tool, arg_keys)` 分桶。
|
||||
2. 对每桶:
|
||||
- `count` = 出现次数;低于阈值(默认 5)直接丢弃。
|
||||
- 对每个 arg_key,统计其值的分布:某值占比 ≥ 80% → 判定为**固定值**(进 `const_args`);
|
||||
否则判定为**可变参数**(沉淀后成为 flag)。
|
||||
- `recency` = 最近一次使用距今;越近权重越高。
|
||||
3. 打分 `score = count * log(distinct_days+1) * recencyDecay`,取 TopN。
|
||||
4. 生成候选 `Candidate{product, tool, fixed{...}, varFlags[...], score, samples}`。
|
||||
|
||||
输出示例(`dws shortcut suggest --format table`):
|
||||
|
||||
```
|
||||
候选 | 命令建议 | 依据 | 固定参数 | 可变flag
|
||||
#1 | chat +notify-team | 12 次 / 近 3 天 | open_conv=cid_x | text
|
||||
#2 | doc +new-agenda | 7 次 / 近 5 天 | template=agenda | title
|
||||
```
|
||||
|
||||
## 4. 主动提示(nudge)
|
||||
|
||||
- **时机**:命令成功收尾时(root `PersistentPostRunE`),**抽样**触发(如每 N 次调用或每次
|
||||
命中新达标候选时),避免打扰。仅在 TTY 交互态提示;非交互(Agent/管道/`--yes`)**不提示**。
|
||||
- **频控**:同一候选提示过一次被拒后,冷却期内不再提示(记 `~/.dws/shortcuts/.declined`)。
|
||||
- **交互**:
|
||||
```
|
||||
💡 检测到高频操作:你已 12 次向同一会话发消息。
|
||||
沉淀为快捷指令 dws chat +notify-team --text "..." ?
|
||||
[y] 沉淀 [n] 以后再说 [d] 不再提示此项
|
||||
```
|
||||
- y → 走 §5 生成 YAML;命名默认 `+<tool 去下划线的动宾>`,允许用户改名。
|
||||
|
||||
## 5. 自定义 Shortcut:YAML 格式与运行时加载
|
||||
|
||||
### 5.1 YAML schema(与 P1 `Shortcut` 一一对应)
|
||||
|
||||
`~/.dws/shortcuts/chat.notify-team.yaml`:
|
||||
|
||||
```yaml
|
||||
version: 1
|
||||
service: chat
|
||||
command: "+notify-team"
|
||||
product: chat
|
||||
description: "发消息到 项目群(自动沉淀于 2026-07-08)"
|
||||
risk: write # 默认 read;send 类判定为 write
|
||||
source: auto # auto=沉淀 / manual=手写
|
||||
flags:
|
||||
- name: text
|
||||
type: string
|
||||
required: true
|
||||
desc: 消息内容
|
||||
execute:
|
||||
tool: send_message
|
||||
bind: # 参数绑定:常量 + ${flag} 模板
|
||||
open_conversation_id: "cid_x"
|
||||
text: "${text}"
|
||||
```
|
||||
|
||||
### 5.2 编译与注册
|
||||
|
||||
`userdef.Compile(yaml)` → `shortcut.Shortcut`,其 `Execute` 由 `bind` 生成:
|
||||
遍历 `bind`,`${flag}` 用 `rt.Str(flag)` 填充,常量原样,组装 params 后 `rt.CallMCP(tool, params)`。
|
||||
完全复用 P1 的 runner,不新增执行路径。
|
||||
|
||||
加载时机:`legacy.go` 装配点,在 `builtin.Commands()` 之后追加 `userdef.Commands()`,
|
||||
一起 merge。复用 `internal/plugin/loader.go` 已验证的「扫 `~/.dws/` 目录 + 挂 cobra」模式。
|
||||
|
||||
### 5.3 冲突与优先级
|
||||
|
||||
- 自定义 shortcut 命令名若与内建 shortcut / helper 冲突:**内建优先**,自定义重命名或跳过并告警。
|
||||
- `+` 前缀天然与 helper leaf 区分,冲突面小。
|
||||
|
||||
## 6. 管理命令面
|
||||
|
||||
```
|
||||
dws shortcut list # 列出内建 + 自定义 shortcut
|
||||
dws shortcut suggest [--min N] # 展示高频候选(不写入)
|
||||
dws shortcut add <candidate|--from-last> # 交互式/从最近一次调用沉淀
|
||||
dws shortcut rm <service> <+cmd> # 删除自定义 shortcut
|
||||
dws shortcut stats # usage 统计概览
|
||||
```
|
||||
|
||||
`dws shortcut` 本身作为一个新的顶层 utility 命令注册(对齐 `dws plugin`)。
|
||||
|
||||
## 7. 安全与隐私边界(红线)
|
||||
|
||||
1. **值不入库**:自由文本/正文/凭证一律不记;`const_args` 仅短 ID/枚举,且过 redact 复核。
|
||||
2. **可关可清**:`DWS_USAGE_TRACKING=0` 关闭;`dws shortcut stats --purge` 清空 usage。
|
||||
3. **执行白名单**:自定义 shortcut 的 `execute.tool` 必须解析到合法 MCP server(过
|
||||
`internal/security` endpoint 白名单),禁止指向任意 endpoint。
|
||||
4. **不自动执行**:沉淀只生成命令定义,**绝不**自动发起写操作;写类 shortcut 仍受 P1 的
|
||||
risk 确认约束。
|
||||
5. **知情**:首次开启埋点一次性告知;提示可永久关闭。
|
||||
|
||||
## 8. 实现顺序(P2 分步,便于 loop 推进)
|
||||
|
||||
- P2-1 usage 埋点:`recordingCaller` 装饰器 + `usage.Append` + jsonl 写 + 开关 + 脱敏白名单。
|
||||
- P2-2 `dws shortcut stats` / `list`:先让数据可见,验证埋点质量。
|
||||
- P2-3 miner + `dws shortcut suggest`:离线挖掘与打分。
|
||||
- P2-4 userdef YAML 加载 + `Compile` + 注册 + 冲突处理(打通「手写 YAML 也能用」)。
|
||||
- P2-5 `dws shortcut add`(从候选/最近调用沉淀)。
|
||||
- P2-6 nudge 主动提示(最后做,最谨慎,默认保守频控)。
|
||||
|
||||
## 9. 待决策点(需产品确认)
|
||||
|
||||
1. 埋点默认开还是默认关?→ **修订后:默认关(opt-in)+ 开启后首跑一次性告知**(原设计默认开,反思后改为 opt-in:自主 agent 不应单方面默认开本地遥测)
|
||||
(`DWS_USAGE_TRACKING=0` / 配置项关闭;`dws shortcut stats --purge` 清空)。实现时以此为准。
|
||||
2. `const_args` 允许记录的字段白名单粒度?(保守起步:只记形如 `*_id/*Id/type/status` 的短值)
|
||||
3. nudge 触发频率与渠道?(建议:仅 TTY、命中新候选时、每候选一生仅一次)
|
||||
4. 自定义 shortcut 是否需要跨设备同步?(v1 先本地 `~/.dws/`,同步留待后续)
|
||||
@@ -0,0 +1,127 @@
|
||||
# DWS Shortcut 能力 — 总体规划
|
||||
|
||||
> 目标:为 dws 引入一套 **声明式高保真命令(Shortcut)** 能力,对齐 larksuite/cli 的 `+command`
|
||||
> 体验(如 `lark-cli contact +search-user`),并在此之上做 dws 差异化:**基于用户高频使用场景,
|
||||
> 主动把常用操作沉淀为自定义 shortcut**。
|
||||
|
||||
## 1. 背景与动机
|
||||
|
||||
dws 当前的命令有三类来源:
|
||||
|
||||
1. **MCP 运行时动态发现** —— `dws mcp <service> <tool> --json '{...}'`,通用但裸、参数需手拼 JSON。
|
||||
2. **`internal/helpers/` 产品命令** —— 手写 cobra 命令,体验好但每个都从零写、缺统一框架。
|
||||
3. **`internal/registry/recipes.yaml`** —— 多步工作流的静态描述。
|
||||
|
||||
痛点:想新增一个「精选、参数友好、带 dry-run/format/身份」的单命令,只能手写 helper,
|
||||
没有统一的声明式框架,重复劳动多、一致性差。larksuite 的 shortcut 框架正好解决这一层。
|
||||
|
||||
## 2. 与 larksuite/cli 的架构差异(关键)
|
||||
|
||||
| 维度 | larksuite/cli | dws-cli |
|
||||
|------|---------------|---------|
|
||||
| 命令来源 | 静态硬编码 Go shortcut | MCP 运行时动态发现 + helpers |
|
||||
| 调用底座 | Lark SDK 直连 API | MCP JSON-RPC(`executor.Runner`) |
|
||||
| 精选命令层 | `shortcuts/`(200+ 声明式) | `internal/helpers/`(手写 cobra) |
|
||||
| 全局 flag | 框架注入 | root 已内建 `--format/--dry-run/--jq/--yes/--fields/--profile` |
|
||||
|
||||
**结论**:不能直接搬代码。移植的是 shortcut 的**声明式设计**,执行底座换成 dws 的
|
||||
`executor.Runner`,全局能力复用 dws 已有的 output/safety/auth。
|
||||
|
||||
## 3. 分期目标
|
||||
|
||||
### P1 — 静态声明式框架(本期,正在做)
|
||||
- 新建独立模块 `internal/shortcut/`(零侵入现有 helpers)。
|
||||
- `types.go`:`Shortcut` / `Flag` / `RuntimeContext` 声明层。
|
||||
- `runner.go`:把 `Shortcut` 编译成 `*cobra.Command`,串起 flag 注册 → 校验 → dry-run →
|
||||
`executor.Runner.Run` → `output.WriteCommandPayload`。
|
||||
- `register.go`:按 service 分组产出命令,在 `internal/app/legacy.go` 装配点 merge 进命令树。
|
||||
- 样板命令 `contact +search-user`:打通 MCP 执行 / format / dry-run / 身份,作为后续命令模板。
|
||||
- 交付判据:`dws contact +search-user --help`、`--dry-run` 正常;`go build` / `go test` 通过。
|
||||
|
||||
### P2 — 高频场景自动沉淀(后续,先设计再实现)
|
||||
- **使用埋点**:命令执行入口记录 `~/.dws/usage.jsonl`(只记参数形状,不记敏感值)。
|
||||
- **模式挖掘**:`dws shortcut suggest` 聚合高频 `(service, tool, 固定参数组合)`。
|
||||
- **主动沉淀**:命中候选时提示用户,一键写入 `~/.dws/shortcuts/*.yaml`(声明式,与 P1 结构对应)。
|
||||
- **运行时加载**:`register.go` 额外扫描 `~/.dws/shortcuts/*.yaml` 动态注册,复用
|
||||
`internal/plugin/loader.go` 已验证的「从 `~/.dws/` 加载并挂 cobra 命令」模式。
|
||||
|
||||
## 4. 落地方式(P1)
|
||||
|
||||
采用**独立模块 + 装配点 merge**,不改 helpers 内部:
|
||||
|
||||
```
|
||||
internal/shortcut/
|
||||
types.go # Shortcut / Flag / RuntimeContext
|
||||
runner.go # 声明式→cobra 编译 + 执行管道
|
||||
register.go # Commands(runner) []*cobra.Command,按 service 分组
|
||||
contact/
|
||||
search_user.go # 样板:var SearchUser = shortcut.Shortcut{...}
|
||||
shortcuts.go # Shortcuts() []shortcut.Shortcut
|
||||
```
|
||||
|
||||
接线:`internal/app/legacy.go: newLegacyPublicCommands` 里,
|
||||
`helpers.NewPublicCommands(runner)` 之后追加 `shortcut.Commands(runner)`,
|
||||
一起走 `mergeTopLevelCommands`(同名 service 命令自动合并,`+xxx` 作为其子命令)。
|
||||
|
||||
复用点:
|
||||
- 执行:`executor.NewHelperInvocation` + `runner.Run`(与 helper 完全一致的调用路径)。
|
||||
- 输出:`output.WriteCommandPayload(cmd, resp, output.FormatJSON)`(自动吃 root 的 `--format/--jq/--fields`)。
|
||||
- dry-run:读 root `--dry-run`,置 `Invocation.DryRun`,由 runner 返回请求预览。
|
||||
- 身份/安全:复用 `--profile`、`internal/safety`(高风险 `--yes` 确认)。
|
||||
|
||||
## 5. Shortcut 声明模型(草案)
|
||||
|
||||
```go
|
||||
type Shortcut struct {
|
||||
Service string // "contact" → 顶层命令
|
||||
Command string // "+search-user" → 子命令(保留 + 前缀,对齐 larksuite)
|
||||
Description string
|
||||
Risk string // read | write | high-risk-write
|
||||
Flags []Flag
|
||||
Validate func(*RuntimeContext) error
|
||||
Execute func(*RuntimeContext) error // 必填;内部调 rt.CallMCP(...)
|
||||
}
|
||||
|
||||
type Flag struct {
|
||||
Name, Type, Default, Desc string
|
||||
Required bool
|
||||
Enum []string
|
||||
}
|
||||
```
|
||||
|
||||
`RuntimeContext` 给 Execute 提供:flag 读取(`Str/Bool/Int/StrSlice/Changed`)、
|
||||
`CallMCP(product, tool, params)`(内部 `runner.Run`)、`Output(payload)`、`DryRun()`。
|
||||
|
||||
## 6. 风险与边界
|
||||
|
||||
- **与 `dws mcp` 通道的边界**:shortcut 是「人工精选的薄封装」,不替代通用 MCP 通道;
|
||||
一个 tool 可以既能 `dws mcp` 直调,也能有 shortcut。
|
||||
- **自定义 shortcut 安全(P2)**:YAML 的 `execute` 若允许任意 MCP 调用,需过
|
||||
`internal/security` 的 endpoint 白名单,且沉淀的参数值要脱敏。
|
||||
- **命名冲突**:`+` 前缀天然与现有 leaf 命令区分,降低与 helper 命令的冲突面。
|
||||
- **edition 差异**:oss / enterprise 的可用 service 不同,注册时按 edition 过滤(后续接入)。
|
||||
|
||||
## 7. 进度看板
|
||||
|
||||
- [x] P1-1 types.go — `Shortcut` / `Flag` / `Risk` 声明层
|
||||
- [x] P1-2 runner.go — `RuntimeContext` + `mount` 编译 + 校验/确认/dry-run;`CallMCP` 委托 `helpers.CallMCPToolOnServer`(复用错误分类/输出/dry-run)
|
||||
- [x] P1-3 register.go + `internal/shortcut/contact/search_user.go` + `builtin` 聚合包
|
||||
- [x] P1-4 接线 `legacy.go`(append 到 `mergeTopLevelCommands`)+ build/test 全绿
|
||||
- [ ] P2 设计文档(进行中)
|
||||
|
||||
### P1 落地实证(已验证)
|
||||
|
||||
- `dws contact +search-user --help`:命令挂载,继承全局 `--format/--dry-run/--jq/...`。
|
||||
- 必填校验:不传 `--query` → 结构化 validation 错误。
|
||||
- `--dry-run`:走 helpers 路径输出 `[DRY-RUN]` 预览(tool + 参数)。
|
||||
- 命令树 merge:`+search-user` 与现有 `user/dept/label/relation` 共存,`contact user search` 未受影响。
|
||||
- 测试:`internal/shortcut` 单测通过;`internal/app` 全量回归通过。
|
||||
|
||||
### P1 关键决策记录
|
||||
|
||||
- **执行底座复用 helpers 而非裸 `runner.Run`**:`CallMCP` 委托 `helpers.CallMCPToolOnServer(product, tool, params)`,
|
||||
一步获得错误分类(auth/PAT/业务)+ 格式化输出 + dry-run,避免重造劣质输出层。代价是
|
||||
`internal/shortcut → internal/helpers` 的单向依赖(无环)。后续若要 shortcut 做多调用编排/输出重塑,
|
||||
再补一个返回原始 payload 的 `CallMCPRaw`。
|
||||
- **避免 import 环**:service 包(contact)import 核心 `shortcut` 包并在 `init()` 注册;
|
||||
`builtin` 聚合包 blank-import 各 service 包;`app` 只依赖 `builtin`。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
# Shortcut 真实测试跟进清单
|
||||
|
||||
生成时间:`2026-07-15T16:58:39`
|
||||
|
||||
来源:`docs/shortcut-real-read-results.json` 与 `docs/shortcut-real-write-results.json`。
|
||||
|
||||
口径:记录真实后端测试中需要继续定位的 case,用于 CR 和问题分派;Agent 使用入口以公开 shortcut catalog 和产品 skill 为准。
|
||||
|
||||
总计:156 条。
|
||||
|
||||
| # | suite | shortcut | risk | status | category | fixability | 处理依据 |
|
||||
|---:|---|---|---|---|---|---|---|
|
||||
| 1 | read | `aitable +base-get-primary-doc-id` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 2 | read | `aitable +chart-share-get` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 3 | read | `aitable +dashboard-share-get` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 4 | read | `aitable +export-data` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 5 | read | `aitable +record-primary-doc-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 6 | read | `aitable +role-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 7 | read | `aitable +workflow-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 8 | read | `aitable +workflow-list` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 9 | read | `attendance +get-class` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 10 | read | `attendance +get-global-setting` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 11 | read | `attendance +get-group` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 12 | read | `attendance +get-group-filtered` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 13 | read | `attendance +get-leave-balance` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 14 | read | `attendance +list-report-columns` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 15 | read | `attendance +query-report-leave` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 16 | read | `calendar +find-room` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 17 | read | `calendar +room-find` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 18 | read | `chat +category-list-conversations` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 19 | read | `chat +chat-get-by-id` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 20 | read | `chat +chat-members-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 21 | read | `chat +chat-messages` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 22 | read | `chat +messages-list` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 23 | read | `chat +messages-resource-url` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 24 | read | `chat +search-msg` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 25 | read | `chat +thread-replies` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 26 | read | `contact +get-roster` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 27 | read | `contact +list-roster-fields` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 28 | read | `devapp +credentials-get` | read | held | held | manual-approval | 高风险或无安全目标,需人工逐项授权后执行。 |
|
||||
| 29 | read | `drive +download` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 30 | read | `drive +list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 31 | read | `minutes +action-items` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
|
||||
| 32 | read | `minutes +latest-minutes` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
|
||||
| 33 | read | `minutes +minutes-search` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 34 | read | `minutes +transcript` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
|
||||
| 35 | read | `oa +done-approvals` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 36 | read | `oa +pending` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 37 | read | `report +report-latest` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 38 | read | `todo +due-today` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 39 | read | `todo +related-tasks` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 40 | read | `wiki +node-list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 41 | read | `wiki +resolve-space` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 42 | read | `wiki +space-list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 43 | write | `aitable +advperm-disable` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 44 | write | `aitable +advperm-enable` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 45 | write | `aitable +attachment-upload` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 46 | write | `aitable +base-copy` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 47 | write | `aitable +base-delete` | high-risk-write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 48 | write | `aitable +base-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 49 | write | `aitable +chart-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 50 | write | `aitable +chart-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 51 | write | `aitable +chart-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 52 | write | `aitable +dashboard-arrange` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 53 | write | `aitable +dashboard-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 54 | write | `aitable +dashboard-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 55 | write | `aitable +dashboard-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 56 | write | `aitable +field-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 57 | write | `aitable +field-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 58 | write | `aitable +form-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 59 | write | `aitable +form-field-hide` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 60 | write | `aitable +form-field-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 61 | write | `aitable +form-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 62 | write | `aitable +form-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 63 | write | `aitable +import-data` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 64 | write | `aitable +import-upload` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 65 | write | `aitable +record-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 66 | write | `aitable +record-primary-doc-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 67 | write | `aitable +record-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 68 | write | `aitable +record-upsert` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 69 | write | `aitable +role-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 70 | write | `aitable +role-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 71 | write | `aitable +role-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 72 | write | `aitable +section-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 73 | write | `aitable +section-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 74 | write | `aitable +section-move-node` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 75 | write | `aitable +section-rename` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 76 | write | `aitable +section-reorder` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 77 | write | `aitable +table-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 78 | write | `aitable +table-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 79 | write | `aitable +view-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 80 | write | `aitable +view-duplicate` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 81 | write | `aitable +view-lock` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 82 | write | `aitable +view-set-fill-color-rule` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
|
||||
| 83 | write | `aitable +view-set-frozen-cols` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 84 | write | `aitable +view-set-row-height` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 85 | write | `aitable +view-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 86 | write | `aitable +workflow-disable` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 87 | write | `aitable +workflow-enable` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
|
||||
| 88 | write | `attendance +boss-check` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 89 | write | `attendance +create-class` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 90 | write | `attendance +create-group` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 91 | write | `attendance +import-schedule` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 92 | write | `attendance +save-leave-balance` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 93 | write | `attendance +update-class` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 94 | write | `attendance +update-group` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 95 | write | `attendance +update-group-members` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 96 | write | `attendance +update-leave-type` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 97 | write | `calendar +respond-event` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 98 | write | `chat +category-add-conversation` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 99 | write | `chat +category-remove-conversation` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 100 | write | `chat +chat-add-bot` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 101 | write | `chat +chat-audit-join` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | dry-run 已证明 CLI 装配了 applicantUid/inviterUid;真实后端仍报 applicantUid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 102 | write | `chat +chat-mute-member` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 103 | write | `chat +chat-quit` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 104 | write | `chat +chat-remove-bot` | high-risk-write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 105 | write | `chat +chat-role-remove` | high-risk-write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 106 | write | `chat +chat-role-remove-user` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 107 | write | `chat +chat-transfer-owner` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 108 | write | `chat +chat-update-icon` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 109 | write | `chat +chat-update-settings` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 110 | write | `chat +conversation-clear-messages` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 111 | write | `chat +conversation-clear-red-point` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 112 | write | `chat +conversation-hide` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 113 | write | `chat +conversation-mark-read` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 114 | write | `chat +conversation-mark-unread` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 115 | write | `chat +conversation-mute` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 116 | write | `chat +conversation-mute-at-all` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 117 | write | `chat +conversation-mute-red-envelope` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 118 | write | `chat +conversation-set-top` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 119 | write | `chat +messages-add-emoji` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 120 | write | `chat +messages-add-text-emotion` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 121 | write | `chat +messages-batch-recall-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 122 | write | `chat +messages-batch-send-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 123 | write | `chat +messages-combine-forward` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 124 | write | `chat +messages-create-text-emotion` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 125 | write | `chat +messages-forward` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 126 | write | `chat +messages-forward-topic` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 127 | write | `chat +messages-recall` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 128 | write | `chat +messages-recall-by-bot` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 129 | write | `chat +messages-remove-emoji` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 130 | write | `chat +messages-remove-text-emotion` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 131 | write | `chat +messages-send-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 132 | write | `chat +messages-send-card` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | dry-run 已证明 CLI 装配了 receiverUid;真实后端仍报 receiverUid/openConversationId 为空,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 133 | write | `chat +messages-set-pin` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 134 | write | `chat +messages-set-top` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 135 | write | `chat +messages-unset-pin` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
|
||||
| 136 | write | `chat +messages-unset-top` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 137 | write | `devapp +event-subscribe` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 138 | write | `devapp +event-unsubscribe` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 139 | write | `devapp +permission-add` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 140 | write | `devapp +permission-remove` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 141 | write | `devapp +robot-config` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 142 | write | `devapp +robot-disable` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 143 | write | `devapp +robot-enable` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 144 | write | `devapp +security-config` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 145 | write | `devapp +version-create` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 146 | write | `devapp +version-publish` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 147 | write | `ding +send-by-message` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 148 | write | `doc +comment-create-inline` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 149 | write | `doc +template-apply` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
|
||||
| 150 | write | `minutes +record-pause` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 151 | write | `minutes +record-resume` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 152 | write | `minutes +record-stop` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
|
||||
| 153 | write | `oa +approve-by` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 154 | write | `wiki +node-copy` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 155 | write | `wiki +node-move` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
| 156 | write | `wiki +wiki-new-doc` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,186 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DWS Shortcut 完成情况报告</title>
|
||||
<style>
|
||||
:root{--bg:#0f1420;--card:#161d2c;--ink:#e6edf6;--muted:#93a1b5;--line:#26314a;
|
||||
--blue:#4f9cff;--green:#3fb950;--yellow:#d5a429;--red:#f25c5c;--accent:#6ea8fe;--purple:#a371f7}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:linear-gradient(180deg,#0d1220,#0f1420);color:var(--ink);
|
||||
font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;padding:0 0 80px}
|
||||
.wrap{max-width:1080px;margin:0 auto;padding:0 22px}
|
||||
header{padding:52px 22px 28px;text-align:center;border-bottom:1px solid var(--line);
|
||||
background:radial-gradient(1200px 300px at 50% -60px,rgba(79,156,255,.16),transparent)}
|
||||
h1{font-size:29px;margin:0 0 8px;letter-spacing:.5px}
|
||||
.sub{color:var(--muted);font-size:14px}
|
||||
.stats{display:flex;gap:13px;justify-content:center;flex-wrap:wrap;margin:26px 0 4px}
|
||||
.stat{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 20px;min-width:118px}
|
||||
.stat .n{font-size:27px;font-weight:700;color:var(--accent)}
|
||||
.stat .l{color:var(--muted);font-size:12.5px;margin-top:2px}
|
||||
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--line)}
|
||||
h2 .ico{color:var(--accent);margin-right:8px}
|
||||
h3{font-size:15.5px;margin:22px 0 9px;color:var(--accent)}
|
||||
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13.5px;background:var(--card);
|
||||
border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||
th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{background:#1b2536;color:var(--muted);font-weight:600;font-size:12.5px}
|
||||
tr:last-child td{border-bottom:none}
|
||||
td.c,th.c{text-align:center}
|
||||
.num{color:var(--accent);font-weight:700;text-align:center}
|
||||
.g{color:var(--green);font-weight:700}.s{color:var(--yellow);font-weight:700}.b{color:var(--red);font-weight:700}
|
||||
.ok{color:var(--green)}.star{color:var(--yellow)}
|
||||
code{background:#0c1120;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:12.5px;color:#cfe0ff}
|
||||
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:15px 18px;margin:13px 0}
|
||||
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||
.layer{border-radius:12px;padding:16px 18px}
|
||||
.l-wrap{background:linear-gradient(180deg,rgba(79,156,255,.08),transparent);border:1px solid #234b6b}
|
||||
.l-smart{background:linear-gradient(180deg,rgba(163,113,247,.10),transparent);border:1px solid #4a3a6b}
|
||||
.layer h3{margin-top:0}
|
||||
.pill{display:inline-block;background:#12283a;color:#7fc6ff;border:1px solid #234b6b;border-radius:6px;padding:1px 7px;font-size:12px;margin:2px 3px 2px 0}
|
||||
.flow{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:13px;color:var(--muted)}
|
||||
.flow b{color:var(--ink)}.flow .arw{color:var(--purple)}
|
||||
.concl{background:linear-gradient(90deg,rgba(63,185,80,.10),transparent);border-left:3px solid var(--green);padding:14px 18px;border-radius:8px;margin-top:16px}
|
||||
.keyfind{background:linear-gradient(90deg,rgba(213,164,41,.10),transparent);border-left:3px solid var(--yellow);padding:14px 18px;border-radius:8px;margin:14px 0}
|
||||
footer{color:var(--muted);text-align:center;font-size:12.5px;margin-top:40px}
|
||||
a{color:var(--accent)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>DWS Shortcut 完成情况报告</h1>
|
||||
<div class="sub">对齐基准 larksuite/cli · 执行底座 钉钉 MCP · 随 loop 持续更新</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="n">366</div><div class="l">shortcut 总数</div></div>
|
||||
<div class="stat"><div class="n">298</div><div class="l">1:1 封装层</div></div>
|
||||
<div class="stat"><div class="n">68</div><div class="l">真·智能层</div></div>
|
||||
<div class="stat"><div class="n">16</div><div class="l">覆盖服务</div></div>
|
||||
<div class="stat"><div class="n">0</div><div class="l">失败/panic/编造</div></div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
|
||||
<h2><span class="ico">①</span>做了什么:两个层次</h2>
|
||||
<p>诚实区分——shortcut 分两层,价值定位不同,不混为一谈。</p>
|
||||
<div class="two">
|
||||
<div class="layer l-wrap">
|
||||
<h3>🔵 1:1 封装层 · 298 条</h3>
|
||||
<div style="color:var(--muted);font-size:13.5px">一个 shortcut ≡ 一个 MCP tool。把裸 <code>dws mcp <svc> <tool> --json '{…}'</code> 收敛成命名 flag,附校验/风险确认/Intent。</div>
|
||||
<div style="margin:10px 0"><b>价值</b>:DX 与 AI-agent 可发现性,<b>不是新能力</b>。</div>
|
||||
<div><span class="pill">命名 flag</span><span class="pill">required/enum 校验</span><span class="pill">风险确认</span><span class="pill">自然语言 Intent</span><span class="pill">dry-run/format</span></div>
|
||||
</div>
|
||||
<div class="layer l-smart">
|
||||
<h3>🟣 真·智能层 · 68 条</h3>
|
||||
<div style="color:var(--muted);font-size:13.5px">照 lark-cli 范式的多步/编排/智能,<b>不是 1:1</b>。框架新增 <code>CallMCPData</code>(多步取数,对标 lark <code>CallAPITyped</code>)+ <code>resolveUser</code>(名→ID,对标 <code>ResolveOpenIDsTyped</code>)。</div>
|
||||
<div style="margin:10px 0"><b>价值</b>:<b>这才是「shortcut 作为新能力」</b>。</div>
|
||||
<div><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">按名解析+消歧</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">多工具编排</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">失败回滚</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">跨服务</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2><span class="ico">②</span>真·智能层 68 条明细(节选)</h2>
|
||||
<table>
|
||||
<thead><tr><th>shortcut</th><th>多步/智能逻辑</th><th class="c">验证</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>chat +dm --to <名></code></td><td>搜人→解析 userId→发单聊;多人消歧</td><td class="c ok">真机 dry-run</td></tr>
|
||||
<tr><td><code>contact +lookup --name <名></code></td><td>搜人→解析→取完整资料</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>todo +assign --to <名></code></td><td>解析人→建待办并设执行人</td><td class="c ok">真机 dry-run</td></tr>
|
||||
<tr><td><code>contact +org --name <名></code></td><td>解析人→取 deptId→查部门详情(3 步)</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>contact +team --name <名></code></td><td>解析人→取部门→列部门成员</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +free --who <名></code></td><td>解析人→查其时段忙闲</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>calendar +book [--with <名CSV>]</code></td><td>建日程→按名加参与者→<b>失败回滚删日程</b></td><td class="c ok">真机 dry-run</td></tr>
|
||||
<tr><td><code>calendar +invite --event --with</code></td><td>解析多人→加入已有日程</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +suggest-time --with</code></td><td>解析多人→推荐可开会时间</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +today</code></td><td>算今天范围→列我今天日程</td><td class="c ok">✅ 真机端到端</td></tr>
|
||||
<tr><td><code>calendar +next-event</code></td><td>近 7 天→取最近一个日程</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>calendar +reschedule --event</code></td><td>查日程详情→改时间</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>chat +send-to-group --group <群名></code></td><td>按群名搜群→消歧→发消息</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>chat +group-members --group <群名></code></td><td>搜群→列群成员</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>chat +broadcast --to <名CSV></code></td><td>多名逐一解析→群发单聊,失败汇总</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>todo +todo-done --task <关键词></code></td><td>列我待办→按标题匹配→标完成</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>todo +remind --task --at</code></td><td>给自己建带提醒的待办</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>wiki +wiki-new-doc --space <名></code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +share-doc --to <名> --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="card">
|
||||
<b>质量亮点(agent 严守 ground truth)</b>:<code>+send-to-group</code> 纠正了「按群名搜群」的正确工具(<code>search_groups</code> 而非按成员昵称的 <code>search_common_groups</code>);<code>+wiki-new-doc</code> 发现 <code>create_file</code> 在 doc server 并正确跨服务路由;<code>+mail-to</code> 因钉钉无 email 字段<b>主动 skip 拒绝编造</b>。
|
||||
</div>
|
||||
|
||||
<h2><span class="ico">③</span>测试验证(零副作用全量)</h2>
|
||||
<p>写/删命令不能真跑,用<b>假 Caller 拦截</b>——每条命令走完「解析→校验→确认→组装 MCP 调用」,捕获组装出的 <code>(product,tool,params)</code>,不真发网络。</p>
|
||||
<table>
|
||||
<thead><tr><th>验证项</th><th>范围</th><th>结果</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>go build ./...</code> / gofmt / vet</td><td>全仓</td><td class="ok">✅ 0 告警</td></tr>
|
||||
<tr><td>TestAllShortcutsAssemble</td><td>全部 366</td><td class="ok">✅ 322 组装真实MCP · 44 自校验 · 0 失败/panic</td></tr>
|
||||
<tr><td>TestAllToolLiteralsAreReal</td><td>tool 字面量</td><td class="ok">✅ 0 编造(比对 helper ground truth)</td></tr>
|
||||
<tr><td>TestAllHaveIntent</td><td>全部 366</td><td class="ok">✅ 每条均有自然语言描述</td></tr>
|
||||
<tr><td>TestNoDuplicateCommands</td><td>全部</td><td class="ok">✅ 无重复 · 命名规范</td></tr>
|
||||
<tr><td>usage / userdef 单测</td><td>埋点/沉淀</td><td class="ok">✅ 全通过</td></tr>
|
||||
<tr><td>app 包全量回归</td><td>internal/app</td><td class="ok">✅ ~72s 通过(未破坏现有命令)</td></tr>
|
||||
<tr><td>智能层真机验证(9 批)</td><td>只读/解析类 20+ 条</td><td class="ok">✅ 端到端返回真实数据、投影正确</td></tr>
|
||||
<tr><td>真机抓修真实 bug</td><td>合成测试盖不住的投影/解析偏差</td><td class="ok">✅ 修复 8 处(resolve-dept/at-me/group-members/free/today/suggest-time/unread-chats/dept-members)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="color:var(--muted);font-size:13px">真机验证补齐了 assemble 测试的盲区:合成响应验证不了「防御式投影是否匹配真实响应结构」。9 批真机验证抓到并修复 8 处解析/投影偏差(如 <code>+resolve-dept</code> 漏了真实容器 key <code>deptList</code>、<code>+at-me</code> 未拍平嵌套、<code>+today/+free</code> 直吐冗长 raw)。少数命令(chat 会话消息读、minutes)因 org/PAT 权限受限无法真机跑通,组装链路仍由 assemble 测试覆盖。</p>
|
||||
|
||||
<h2><span class="ico">④</span>效果评估 GSB(vs lark-cli)</h2>
|
||||
<h3>4.1 能力覆盖 GSB(按 dws 实际暴露的 MCP tool 数 = helper∪shortcut,非 shortcut 数)</h3>
|
||||
<table>
|
||||
<thead><tr><th>lark 服务</th><th class="c">lark</th><th>dws</th><th class="c">dws</th><th class="c">GSB</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>im</td><td class="c">21</td><td>chat</td><td class="c">95</td><td class="c g">G</td><td>群/消息/机器人更全</td></tr>
|
||||
<tr><td>mail</td><td class="c">21</td><td>mail</td><td class="c">43</td><td class="c g">G</td><td>覆盖更广</td></tr>
|
||||
<tr><td>doc</td><td class="c">14</td><td>doc</td><td class="c">34</td><td class="c g">G</td><td>块级读写更细</td></tr>
|
||||
<tr><td>minutes</td><td class="c">14</td><td>minutes</td><td class="c">25</td><td class="c g">G</td><td>录音/说话人更全</td></tr>
|
||||
<tr><td>calendar</td><td class="c">12</td><td>calendar</td><td class="c">24</td><td class="c g">G</td><td>会议室/ACL 更全</td></tr>
|
||||
<tr><td>contact</td><td class="c">2</td><td>contact</td><td class="c">15</td><td class="c g">G</td><td>部门/角色/花名册更全</td></tr>
|
||||
<tr><td>wiki</td><td class="c">12</td><td>wiki</td><td class="c">16</td><td class="c g">G</td><td>略优</td></tr>
|
||||
<tr><td>base</td><td class="c">87</td><td>aitable</td><td class="c">79</td><td class="c s">S</td><td>持平;helper 仅 16,<b>shortcut 补齐 +63</b>(真 gap-fill)</td></tr>
|
||||
<tr><td>task</td><td class="c">18</td><td>todo</td><td class="c">20</td><td class="c s">S</td><td>持平</td></tr>
|
||||
<tr><td>drive</td><td class="c">26</td><td>drive</td><td class="c">25</td><td class="c s">S</td><td>持平</td></tr>
|
||||
<tr><td>apps</td><td class="c">63</td><td>devapp</td><td class="c">25</td><td class="c b">B</td><td><b>helper 无 devapp,25 全由 shortcut 补</b>,但仍少于 lark</td></tr>
|
||||
<tr><td>sheets</td><td class="c">84</td><td>sheet</td><td class="c">60</td><td class="c b">B</td><td>钉钉表格 MCP 较少;helper 已覆盖</td></tr>
|
||||
<tr><td>vc/okr/slides/markdown/whiteboard/note/event</td><td class="c">62</td><td>—</td><td class="c">0</td><td class="c b">B</td><td>钉钉无对应能力,<b>客观不可对齐</b></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="color:var(--muted);font-size:13px;margin:6px 0 2px">🟢 G 7 领域 · 🟡 S 3 领域 · 🔴 B(apps/sheets 少于 lark + 6 领域钉钉无能力)。base 的"持平"几乎全靠 shortcut gap-fill(helper 仅 16)。dws 独有:oa/attendance/report/ding/aisearch/live/devdoc。</div>
|
||||
|
||||
<h3>4.2 组合/智能层 GSB(关键发现)</h3>
|
||||
<div class="keyfind">
|
||||
<b>关键结论</b>:lark 有 ~104 个组合(≥2 次 API)shortcut,但 <b>lark 的组合性多源于飞书 REST API 太细粒度</b>(要先查 spreadsheetToken→sheetId→再操作);<b>钉钉 MCP 是粗粒度的——一个 tool = 一个完整操作</b>,所以 lark 的组合在钉钉这边<b>大量塌缩成 1:1</b>(已被封装层覆盖),或<b>根本没有对应 tool</b>。
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>lark 组合来源</th><th class="c">数量</th><th class="c">GSB</th><th>钉钉现实</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>sheets(先解析 sheetId)</td><td class="c">41</td><td class="c s">S</td><td>钉钉直接吃 token → 1:1 层已覆盖</td></tr>
|
||||
<tr><td>apps db-env/audit/log/trace</td><td class="c">17</td><td class="c b">B</td><td>钉钉无对应工具</td></tr>
|
||||
<tr><td>drive/doc/im(上传/媒体/搜索)</td><td class="c">23</td><td class="c s">S</td><td>多为钉钉 1:1 已覆盖</td></tr>
|
||||
<tr><td>calendar/contact/wiki/minutes/todo 编排</td><td class="c">~10</td><td class="c g">G</td><td>✅ 已建为真·智能 shortcut(+book/+lookup/+org/+wiki-new-doc/+reschedule…)</td></tr>
|
||||
<tr><td>okr/whiteboard/slides/vc</td><td class="c">11</td><td class="c b">B</td><td>钉钉无对应能力</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style="color:var(--muted);font-size:13px">→ 钉钉真正需要「组合」的场景(按名解析+多工具编排+跨服务),dws 已覆盖并<b>额外做了 lark 没有的</b>(+today/+broadcast/+share-doc/+action-items 等)。<b>不盲目复刻 lark 的机械多步</b>(在钉钉会成冗余假组合)。</div>
|
||||
|
||||
<h2><span class="ico">⑤</span>dws 差异化优势</h2>
|
||||
<div class="card"><b>复用生产级 MCP 通道</b>(架构性)—— <code>CallMCP</code> 统一继承错误分类(auth/PAT/业务)、dry-run、<code>--format/--jq/--fields</code>;lark 每命令各自实现。</div>
|
||||
<div class="card"><b>协作能力覆盖更全</b> —— chat 95/mail 43/doc 34/minutes 25(dws tool 覆盖)是 lark 对应 2–4 倍。</div>
|
||||
<div class="card"><b>钉钉原生特有能力</b>(lark 完全没有)—— 审批/日志/考勤/DING/企业智能搜索,75 条差异化封装。</div>
|
||||
<div class="card"><b>真·智能编排(22 条)</b> —— 按名解析+消歧、失败回滚、跨服务;<code>resolveUser</code>/<code>CallMCPData</code> 让新智能 shortcut 越写越快。</div>
|
||||
<div class="card"><b>高频自动沉淀(P2 · lark 无此设计)</b>
|
||||
<div class="flow" style="margin-top:9px"><b>高频使用</b><span class="arw">→</span><b>埋点</b><span class="arw">→</span><b>suggest</b><span class="arw">→</span><b>add 写 YAML</b><span class="arw">→</span><b>运行时加载可用</b></div>
|
||||
</div>
|
||||
<div class="card"><b>工程质量</b> —— 假 Caller 拦截,366 条(含写/删)零副作用全量验证,可复跑回归。</div>
|
||||
|
||||
<div class="concl">
|
||||
<b>一句话结论</b>:dws 已把钉钉侧<b>能对齐的都对齐</b>(366 条 = 298 封装 + 68 智能 / 16 服务,1:1 层已去 213 条纯重复),即时协作显著优于 lark,拥有审批/考勤/DING 等原生差异化能力与「高频自动沉淀」独有闭环。lark 的组合优势多因飞书 API 细粒度、在钉钉粗粒度 MCP 下塌缩为 1:1(已覆盖),真正需编排的钉钉侧已建齐;受限项均为钉钉客观无对应能力,非工程遗漏。全部 366 条通过零副作用全量验证。
|
||||
</div>
|
||||
|
||||
<footer>DWS Shortcut Report · 由持续精进 loop 维护 · 另见 <a href="shortcut-comparison.html">逐条三方对照 HTML</a> · <a href="shortcut-report.md">Markdown 版</a></footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
# DWS Shortcut 能力整合与对齐报告
|
||||
|
||||
> 版本:截至本轮 loop | 对齐基准:larksuite/cli(lark-cli) | 执行底座:钉钉 MCP
|
||||
> 相关文档:[总规划](shortcut-plan.md) · [P2 自动沉淀设计](shortcut-p2-design.md) · [HTML 报告](shortcut-report.html) · [**逐条三方对照 HTML**](shortcut-comparison.html)(每个 shortcut:dws +命令 vs lark-cli vs 原生 MCP 组合)
|
||||
|
||||
---
|
||||
|
||||
## 1. Shortcut 整合了哪些能力
|
||||
|
||||
`dws` 现内建 **298 个 1:1 封装 shortcut** + **68 条 smart 智能编排命令**(§1.3)= **合计 366 条**,覆盖 **16 个钉钉服务**,以 `dws <service> +<command>` 形式提供。
|
||||
|
||||
> ⚠️ **重要修订(去冗余)**:1:1 层原为 511 条,**复盘发现 `internal/helpers/` 早已把大量 MCP tool 封装成 `dws <svc> <verb>` 产品命令**——其中 **213 条 1:1 shortcut 只是把已被 helper 封装过的同一个 tool 用 `+` 前缀又封了一遍、且无输出投影增量,属纯重复**,已删除。**保留的 298 条 = 233 条填 helper 空白(helper 从没封装的 tool)+ 65 条虽 tool 重复但加了干净投影**。这是对"建 1:1 层前没先摸清 helper 已封装什么"的纠偏(详见 §5 复盘)。aisearch/live/devdoc 三个服务的 shortcut 全属纯重复、已整包移除(其 `dws <svc>` 命令仍由 helper 层提供)。
|
||||
|
||||
### 1.1 能力清单(按服务,prune 后)
|
||||
|
||||
| 服务 | 1:1 shortcut 数 | 覆盖能力(摘要) |
|
||||
|------|:---:|------|
|
||||
| chat(群聊/消息) | 79 | 群管理、群成员、群身份角色、消息收发/撤回/转发/表情/卡片、会话置顶/免打扰、消息分组、机器人 |
|
||||
| aitable(多维表 base) | 77 | 数据表/字段/记录/视图/表单/仪表盘/图表/角色/协作全生命周期 |
|
||||
| attendance(考勤)★ | 33 | 打卡记录、审批、排班、班次、考勤组、统计报表、请假 |
|
||||
| devapp(开放平台应用 apps) | 30 | 应用增删改查、成员、权限、版本发布、事件订阅、扩展机器人/H5 配置 |
|
||||
| doc(文档) | 16 | 文档/文件夹、正文块读写、权限、附件、节点 |
|
||||
| contact(通讯录) | 9 | 用户/部门搜索与详情、角色、花名册 |
|
||||
| drive(钉盘) | 8 | 文件/文件夹管理、下载、复制移动、权限、最近访问 |
|
||||
| calendar(日历) | 8 | 日程、参与人、会议室、忙闲、ACL、日历本 |
|
||||
| minutes(AI 听记) | 7 | 妙记详情/逐字稿、录音控制、说话人 |
|
||||
| oa(审批)★ | 6 | 审批实例、单据处理、模板、流程 |
|
||||
| mail(邮箱) | 6 | 邮件搜索/线程、标签、联系人、收信规则(投影类保留) |
|
||||
| wiki(知识库) | 5 | 知识空间、节点、成员 |
|
||||
| todo(待办 task) | 5 | 待办、子任务、执行人/参与人、附件 |
|
||||
| ding(DING)★ | 5 | 机器人/个人 DING 发送、撤回、接收状态 |
|
||||
| sheet(钉钉表格) | 2 | 区域读写(投影类保留) |
|
||||
| report(日志)★ | 2 | 日志收件箱/发件箱 |
|
||||
| **合计** | **298** | **16 个服务** |
|
||||
|
||||
★ = 钉钉特有服务,lark-cli 无对应(详见 §3 GSB)。**注**:多数服务的 `shortcut 数` 已远小于该服务的 MCP tool 总数——因为 tool 的基础封装由 helper 层的 `dws <svc> <verb>` 命令承担,1:1 shortcut 只保留 helper 没覆盖的、或加了投影的。
|
||||
|
||||
### 1.2 每个 shortcut 统一具备的能力(框架注入)
|
||||
|
||||
不是简单命令别名,而是叠加在裸 MCP 之上的**精选薄封装**,统一获得:
|
||||
|
||||
- **声明式定义**:`Shortcut{Service, Command, Product, Risk, Flags, Execute}`,一处声明、框架编译成 cobra 命令。
|
||||
- **自然语言 Intent**:每条 shortcut 均带一段自然语言描述(做什么/何时用/关键输入产出,写删类点明副作用),面向用户与 AI agent 的意图匹配;`--help` 展示为长描述,`dws shortcut list` 输出 `intent` 字段。全部 366 条覆盖(`TestAllHaveIntent` 强制校验)。
|
||||
- **参数收敛**:把裸 `dws mcp <svc> <tool> --json '{...}'` 的手拼 JSON,收敛成命名 flag(`--query`/`--group`…)。
|
||||
- **内建校验**:required / enum 声明式校验,结构化错误提示。
|
||||
- **风险确认**:read / write / high-risk-write 分级,写/删操作 `--yes` 前二次确认。
|
||||
- **复用生产级 MCP 通道**:错误分类(auth/PAT/业务)、`--dry-run` 预览、`--format`/`--jq`/`--fields` 输出,全部免费继承(详见 §4)。
|
||||
|
||||
### 1.3 两个层次:1:1 封装层 vs 真·多步/智能层(重要澄清)
|
||||
|
||||
诚实区分——上面 298 条**绝大多数是 1 shortcut ≡ 1 个 MCP tool 的 1:1 封装**,本质是「给 MCP 套命名 flag + 校验 + Intent 的友好外壳」,价值在 DX 与 agent 可发现性,**不是新能力**。
|
||||
|
||||
真正的「shortcut 作为新能力」是 `internal/shortcut/smart/` 下的**多步/智能** shortcut——照 larksuite/cli 的实现范式(`CallAPITyped` 链式多步、按名解析 ID、Validate、DryRun 计划、失败回滚)落地。框架为此新增 `RuntimeContext.CallMCPData(product, tool, params)`(对应 lark 的 `CallAPITyped`:调用并返回 data 供下一步,跨服务)。
|
||||
|
||||
已落地的真·智能 shortcut(`internal/shortcut/smart/`,共 68 条,下表为代表性节选):
|
||||
|
||||
| shortcut | 多步/智能逻辑 | 验证 |
|
||||
|----------|--------------|------|
|
||||
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
|
||||
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
|
||||
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
|
||||
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
|
||||
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
|
||||
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
|
||||
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
|
||||
| `calendar +suggest-time --with <姓名CSV>` | 解析多人→推荐可开会时间 | ✅ 编译/挂载 |
|
||||
| `calendar +invite --event <id> --with <姓名CSV>` | 解析多人→加入已有日程 | ✅ 编译/挂载 |
|
||||
| `doc +share-doc --to <姓名> --url` | 解析人→把文档链接私信 TA | ✅ 编译/挂载 |
|
||||
| `calendar +today` | 算出今天时间范围→列我今天的日程 | ✅ **真机端到端**(返回真实日程+参会人) |
|
||||
| `calendar +next-event` | 近 7 天日程→按时间取最近一个 | ✅ 编译/挂载 |
|
||||
| `contact +team --name <姓名>` | 解析人→取部门→列部门直接成员 | ✅ 编译/挂载 |
|
||||
| `todo +remind --task --at` | 给自己建带截止/提醒时间的待办 | ✅ 编译/挂载 |
|
||||
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
|
||||
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
|
||||
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
|
||||
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
|
||||
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
|
||||
|
||||
| `oa +approve-by --keyword` ★ | 列待审批→匹配→取 taskId→通过(钉钉原生,lark 无) | ✅ 编译/挂载 |
|
||||
| `attendance +my-attendance` ★ | 当前用户→算今天→查我打卡(路由 attendance-wukong server) | ✅ 编译/挂载 |
|
||||
| `todo +overdue` | 列我待办→本地过滤过期→投影输出 | ✅ **真机端到端** |
|
||||
| `report +report-latest` ★ | 列我日志→取最新→取详情 | ✅ 编译/挂载 |
|
||||
| `aitable +find-record --base --table` | 表内按关键词查记录 | ✅ 编译/挂载 |
|
||||
|
||||
另有 gap-buildable 补齐(批6):`chat +my-groups`(列群+类型过滤+投影)、`calendar +find-room`(时段找可用会议室)、`minutes +minutes-search`(关键词搜妙记)、`mail +search-mail`(搜邮件+自动解析绑定邮箱)、`drive +find-file`(搜钉盘文件+投影)。
|
||||
|
||||
批7-8 续补(10 条):`chat +at-me`(近期@我)、`calendar +cancel-event`(查→删,高危二次确认)、`todo +assign-multi`(多人指派)、`contact +dept-members`(搜部门→列成员)、`minutes +transcript`(最新妙记逐字稿)、`calendar +week`(本周日程)、`contact +by-mobile`(手机号→资料)、`todo +created-todos`(我创建的)、`chat +unread-chats`(未读会话)、`mail +unread-mail`(未读邮件)。
|
||||
|
||||
批9 续补(3 条·手工,对齐 gap-buildable):`minutes +detail`(单命令聚合一条听记的 basic/summary/keywords/transcript/todos,partial-failure 容错)、`minutes +replace-batch`(多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合,补齐一次一组的 1:1 `+word-replace`)、`aitable +record-share-links`(>20 条记录分享链接:去重+分片(≤20/批)+跨 `aitable-helper` server fanout+合并,补齐单批 20 条上限)。
|
||||
|
||||
批10 续补(3 条·多 agent 并行,对齐 gap-buildable):`chat +thread-replies`(拉某条话题消息的全部回复 list_topic_replies + sender/text/time 投影)、`todo +related-tasks`(creator+executor+participant 三角色并集「与我相关的待办」+ taskId 去重 + 投影)、`doc +find-doc`(按关键词搜云文档 search_documents + title/url/type/token 投影)。
|
||||
|
||||
批11 续补(3 条·多 agent 并行):`aitable +resolve-base`(按名搜 Base 解析 baseId,0/1/多候选消歧 search_bases)、`chat +chat-messages`(群/单聊会话消息列表,list_conversation_message_v2 / list_individual_chat_message 互斥+投影)、`mail +find-mail-user`(按名/邮箱搜企业邮箱联系人 search_mail_users + 投影)。
|
||||
|
||||
批12 续补(3 条·多 agent 并行,dws 原生 resolver 层,按名解析 ID):`wiki +resolve-space`(search_wikiSpaces 名→spaceId)、`aitable +resolve-table`(get_tables 在 Base 内名→tableId,本地匹配)、`contact +resolve-dept`(search_dept_by_keyword 名→deptId,含数值 ID 兼容)。均 0/1/多候选消歧,对标 `resolveUser` 的各资源版。
|
||||
|
||||
批28 续补(3 条·净新增便利读,dws 原生):`oa +pending`(**只读**列待我审批,区别于会审批的 +approve-by)、`todo +due-today`(今天到期待办,planFinishDate 服务端过滤,区别于 +overdue 已过期)、`calendar +tomorrow`(明天日程,复用 +today/+week 投影)。均只读、真机验证(+tomorrow 返回真实明日日程;+pending/+due-today 空路径正确且复用已验证 helper)。
|
||||
|
||||
批29 续补(3 条·净新增便利读,dws 原生):`oa +done-approvals`(我已处理的审批历史 get_done_tasks;真机抓到并修复 pageSize=0 → 默认 20 的后端报错 bug)、`mail +recent-mail`(近期收件箱会话 list_mailbox_threads + 解析绑定邮箱/收件箱)、`attendance +this-month`(本月打卡 query_check_record on attendance-wukong,复用 +my-attendance 自身解析)。真机:+this-month 返回有效空、+done-approvals 修后走空路径、+recent-mail 正确报未绑定邮箱。
|
||||
|
||||
批30 续补(1 条·净新增便利读):`contact +me`(当前用户 get_current_user_profile + 投影 {name,userId,mobile,dept,org,email},agent 的「我是谁」;区别于 1:1 +get-self 吐冗长 raw,真机验证 董鑫阳/202397/模型算法)。
|
||||
|
||||
批31 续补(1 条·净新增便利读):`calendar +my-free`(我自己的忙闲,自动解析当前 userId,默认今天,复用 +free 的 freebusySlots 投影;无需像 +free 传别人姓名,真机验证返回今日忙碌时段)。
|
||||
|
||||
批32 续补(1 条·净新增 dws 原生编排,lark 也没有):`calendar +conflicts`(检测某天日程时间冲突/双重预订,list_calendar_events + 本地两两重叠检测,默认今天/--in-days;真机验证抓到今日 2 处真实冲突)。这类纯 MCP-tool 的本地编排是复杂写死胡同之外仍有价值的方向。
|
||||
|
||||
批33 续补(1 条·净新增 dws 原生编排,+conflicts 的互补品):`calendar +free-slots`(找某天工作时段内的空闲时段"什么时候能安排会",list_calendar_events + 合并忙碌区间 + 工作窗口内求补集,默认今天 09:00-18:00/--from/--to/--in-days;真机验证今日 4 段空档)。
|
||||
|
||||
共 **68 条真·智能 shortcut**(多批多 agent 工作流并行生成 + 手工续补)。★=钉钉原生编排,lark 完全没有。
|
||||
|
||||
**真机验证(登录态抽样,返回真实数据)**:`calendar +today/+week`(真实日程+投影)、`contact +org`(3 步→部门详情)、`contact +lookup/+free`、`todo +overdue`、`attendance +my-attendance` 等端到端可用。
|
||||
|
||||
### 深度对齐矩阵(逐条分析 lark 361 条 shortcut)
|
||||
|
||||
见 [`shortcut-lark-alignment.md`](shortcut-lark-alignment.md)——12 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉:
|
||||
|
||||
| dws_status | 数量 | 含义 |
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 (40%) | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 (35%) | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **42 (12%)** | 钉钉有工具、值得补成智能 shortcut(建设目标) |
|
||||
| covered-smart | 48 (13%) | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
**框架系统性能力已对齐 lark**:`resolveUser`(名→ID)· `CallMCPData`(多步取数)· `rt.Output`(输出投影)· `rt.MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/RequireAll`(跨字段校验)。
|
||||
|
||||
### 保真度升级(对齐 lark 96% 的输出投影)
|
||||
|
||||
lark 96% 的 shortcut 都做**输出投影**(把原始 API 返回精简为干净字段列表)。已给 **~60 条列表/读类封装**升级到此保真度——从 `rt.CallMCP`(打印原始 MCP 返回)改为 `rt.CallMCPData` + 防御式投影 + `rt.Output`(自动吃 `--format/--jq/--fields`):
|
||||
|
||||
`contact +search-user/+search-mobile/+list-roles/+list-sub-depts` · `todo +get-my-tasks/+list-sub` · `calendar +book-list/+attendee-list` · `drive +list` · `wiki +node-list` · `chat +conversation-list/+category-list/+messages-list-unread-conversations/+messages-list-pin` · `doc +search/+list` · `mail +tag-list/+contact-list` · `aitable +base-list/+base-search` · `oa …`
|
||||
|
||||
示例:`contact +search-user` 由原始 MCP 返回 → 干净 `{count, users:[{name,userId,flowerName,openDingTalkId,title}]}`(真机验证)。这是把封装层往 lark 高保真水平系统性拉升的开始。另有 `mail +to`(按名发邮件)被 agent **正确 skip**——钉钉 contact 无 email 字段、mail `send_email` 需发件人邮箱 `from` 无法解析,宁缺勿错不编造。关键复用:「按名解析人」抽成共享 helper `resolveUser`(对标 lark `ResolveOpenIDsTyped`,带 0/多人消歧,不瞎猜);多步靠 `CallMCPData`(对标 lark `CallAPITyped`)。其中 5 条由多 agent 工作流并行生成——各自以 helper 为 ground truth 研究参数、`send-to-group` 的 agent 还主动纠正了「按群名搜群」的正确工具(`search_groups` 而非按成员昵称的 `search_common_groups`)。
|
||||
|
||||
> 定位:1:1 层是「MCP 友好外壳」,smart 层才是「真 shortcut」。二者不混淆。
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试验证报告
|
||||
|
||||
**验证理念**:写/删命令不能真跑(会发消息、解散群、删数据),故用**假 Caller 拦截**——让每条命令(含写/删)真实走完「解析→校验→确认→组装 MCP 调用」全流程,捕获组装出的 `(product, tool, params)`,只是不真发网络。以此对**全部 366 条**做零副作用验证。
|
||||
|
||||
### 2.1 结果总览(全绿)
|
||||
|
||||
| 验证项 | 范围 | 结果 |
|
||||
|------|------|------|
|
||||
| `go build ./...` | 全仓 | ✅ 通过 |
|
||||
| `gofmt -l` / `go vet` | shortcut 全包 | ✅ 0 未格式化 / 0 告警 |
|
||||
| 框架单元测试(5) | 类型/挂载/校验/分组 | ✅ 全通过 |
|
||||
| **TestAllShortcutsAssemble** | **全部 366 条** | ✅ 322 组装真实 MCP · 44 自校验拦截 · **0 失败 · 0 panic** |
|
||||
| **TestAllToolLiteralsAreReal** | 全部 tool 字面量(逐条) | ✅ **0 编造**(tool 名逐一比对 helper ground truth) |
|
||||
| TestNoDuplicateCommands | 全部 366 条 | ✅ 无重复、命名规范(均 `+` 前缀) |
|
||||
| **TestAllHaveIntent** | 全部 366 条 | ✅ 每条均有自然语言 Intent 描述(无一遗漏) |
|
||||
| usage 包单测(4) | 埋点/脱敏/聚合/开关 | ✅ 全通过 |
|
||||
| app 包全量回归 | `internal/app` | ✅ 72.2s 通过(接线未破坏任何现有命令) |
|
||||
| 只读命令真机验证 | ~25 条(登录态,见 §2.4) | ✅ `contact +me`/`+org`/`+lookup`、`calendar +today/+week/+free/+conflicts/+free-slots`、`doc +find-doc`、`aitable +resolve-base/+resolve-table`、`drive +find-file`、`oa +my-initiated` 等端到端返回真实数据、投影核对 |
|
||||
|
||||
### 2.2 关键指标解读
|
||||
|
||||
- **322 「组装真实 MCP」**:喂合成参数后成功组装出 MCP 调用,且 tool 名经 helper ground truth 核验真实、非编造。
|
||||
- **44 「自校验拦截」**:这些命令有结构化/JSON/互斥输入(如多维表建记录需 JSON、DING 三选一接收人),dummy 值被其**自身校验正确拒绝**——证明校验链路健全。其 tool 名由静态测试 `TestAllToolLiteralsAreReal` 单独覆盖,无遗漏。
|
||||
- **0 编造 / 0 panic / 0 失败**:无幻觉工具名,无运行时崩溃,无死命令。
|
||||
|
||||
> ⚠️ **验证强度分层(诚实口径,勿把"全绿"读成"真机全对")**:`TestAllShortcutsAssemble` 的"0 失败"只证明**能正确组装 MCP 调用、零副作用**——它用**合成响应**,**验证不了防御式投影是否匹配真实响应结构**(真机验证正是靠这个抓到过 deptList 容器、pageSize=0 等 assemble 盖不住的 bug,见 §2.4)。按真机验证强度分三层:**(A) 真机正向验证** ~25 条只读/解析类(返回真实数据、投影核对);**(B) 仅 assemble + 复用已验证 helper**(如 +due-today/+this-month 等,逻辑同构于已验证命令,但该条本身未在真机跑出正样本);**(C) 未对真实后端跑过**——17 条写类 smart(不宜真跑,会发消息/建数据)、6 条 minutes smart(该 org 未开 CLI 数据访问)、mail +recent-mail(无绑定邮箱)。(C) 类**很可能仍有 assemble 盖不住的投影/参数 bug**,不应因"全绿"就当作"真机可用"。
|
||||
|
||||
### 2.3 防幻觉机制(工作流生成时)
|
||||
|
||||
生成阶段每个服务由独立 agent 负责,硬性规则:tool 名与参数 key **只能逐字取自 dws helper 的真实调用点**,无法确定参数的 tool 主动跳过并记录原因(如嵌套对象、时间戳转换、本地文件分片上传)。测试阶段再用 ground truth 二次核验,双重保险。
|
||||
|
||||
### 2.4 真机验证战役(登录态打真实钉钉后端)
|
||||
|
||||
assemble 测试用**合成响应**,能验证「调用是否组装正确」,但验证不了「防御式投影解析是否匹配真实响应结构」。为此做了 9 批真机验证(登录态 corp「钉钉」,token 有效期内),把只读/解析类 smart shortcut 打真实后端、逐条核对投影输出。
|
||||
|
||||
**正向验证 20+ 条**(返回真实数据、投影正确):`doc +find-doc`、`aitable +resolve-base`/`+resolve-table`/`+list-tables`/`+base-list`/`+find-record`、`mail +find-mail-user`、`chat +my-groups`/`+group-members`/`+at-me`、`contact +org`/`+lookup`、`calendar +today`/`+week`/`+next-event`/`+free`/`+suggest-time`、`todo +overdue`/`+related-tasks`、`attendance +my-attendance`、`report +report-latest`、`oa +my-initiated`、`drive +find-file`、`wiki +resolve-space` 等。
|
||||
|
||||
**真机抓到并修复 8 个真实问题**(assemble 测试抓不到,只有真机能抓):
|
||||
|
||||
| shortcut | 真机发现的问题 | 修复 |
|
||||
|---|---|---|
|
||||
| `contact +resolve-dept` | 对任何真实部门名都「未找到」——真实响应容器 key 是 `deptList`(防御探测清单漏了),deptName 带 `<red>` 高亮、deptId 是数值 | 加 `deptList` 探测 + `stripHighlightTags` + 数值 coerce |
|
||||
| `contact +dept-members` | 消歧消息泄漏 `<red>` 标记 | 复用 `stripHighlightTags` |
|
||||
| `chat +unread-chats` | 每行吐 `unread: null`(底层不返回每会话未读数) | 仅当有值才带该字段 |
|
||||
| `chat +at-me` | 直吐原始两层嵌套、未拍平 | 新增 `atMeFlattenGroups`,拍平 43 条为 `{conversation,sender,text,time}` |
|
||||
| `chat +group-members` | 终结步 raw `CallMCP` 吐冗长 raw(含 avatar 媒体 ID + errorCode 噪音) | 升级 `CallMCPData`+投影 `{name,nick,role,openDingtalkId}` |
|
||||
| `calendar +free` | 吐冗长 `result[].scheduleItems[].{start,end}.dateTime` 嵌套 | 投影为 `{who,userId,free,busy:[{start,end}]}` |
|
||||
| `calendar +today` | 吐 17 字段冗长事件(含完整 attendees 数组),与 `+week` 不一致 | 投影为 `{title,start,end,location,eventId}`,对齐 `+week` |
|
||||
| `calendar +suggest-time` | `timeConflictAttendees:[null]` 噪音 + result 包裹 | 拍平 + 丢 null 冲突 → `{suggestions:[{start,end}]}` |
|
||||
|
||||
**后端受限、无法真机正向验证的(非代码问题)**:`chat +chat-messages`/`+search-msg`(读会话消息需更高 PAT 权限 / org 未开 CLI 数据访问)、`minutes +*`(org 未开 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)、`contact +team`(列部门成员 medium-risk 权限墙)。这些命令的**组装链路**经 assemble 测试验证正确,仅无法在本环境跑通后端。
|
||||
|
||||
**一个已澄清的非 bug**:`resolveUser` 对组织内成员(如董鑫阳→userId 202397)真机端到端正常;对外部/资料受限联系人 `search_contact_by_key_word` 只返回 openDingTalkId(name/userId 全 null),此时正确报「没找到」而非瞎猜——钉钉数据模型现实,非代码缺陷。
|
||||
|
||||
> **结论**:真机验证证明「防御式多候选 key 投影」在真实响应上整体成立,并纠正了 8 处「合成测试盖不住」的解析/投影偏差。这是把可用性从「组装正确」提升到「真机输出正确」的关键一环。
|
||||
|
||||
---
|
||||
|
||||
## 3. 效果评估 GSB(vs lark-cli)
|
||||
|
||||
以 lark-cli 各服务领域为基准,评估 dws shortcut 的相对表现。**G**ood=优于/更全,**S**ame=持平,**B**ad=弱于/缺失。
|
||||
|
||||
> ⚠️ 重要前提:两边是**不同 API**(飞书 vs 钉钉),数量不能机械 1:1;覆盖度受钉钉实际能力约束。
|
||||
>
|
||||
> **口径(prune 后重做)**:不再用 shortcut 数(会因去冗余失真),改用**「dws 该服务实际暴露的 distinct MCP tool 数」= helper 命令 ∪ shortcut 覆盖的 tool 合集**——这才代表真实能力,与 helper/shortcut 怎么分层无关。对比 lark 的 shortcut 数(不同 API,只作量级参考)。
|
||||
|
||||
| lark 服务 | lark 数 | dws 对应 | dws tool 覆盖 | (其中 shortcut 补) | GSB | 说明 |
|
||||
|-----------|:---:|---------|:---:|:---:|:---:|------|
|
||||
| im | 21 | chat | **95** | +3 | 🟢 G | 群/消息/机器人能力更全 |
|
||||
| mail | 21 | mail | **43** | +0 | 🟢 G | 覆盖更广(几乎全由 helper 提供,shortcut 曾重复、已 prune) |
|
||||
| doc | 14 | doc | **34** | +4 | 🟢 G | 块级读写更细 |
|
||||
| minutes | 14 | minutes | **25** | +0 | 🟢 G | 录音控制/说话人更全 |
|
||||
| calendar | 12 | calendar | **24** | +5 | 🟢 G | 会议室/ACL/忙闲;shortcut 另补排期智能 |
|
||||
| contact | 2 | contact | **15** | +0 | 🟢 G | 部门/角色/花名册更全 |
|
||||
| wiki | 12 | wiki | **16** | +0 | 🟢 G | 略优 |
|
||||
| base | 87 | aitable | **79** | **+63** | 🟡 S | 基本持平;**helper 仅 16,shortcut 层补齐了绝大部分**(真·gap-fill) |
|
||||
| task | 18 | todo | **20** | +1 | 🟡 S | 持平 |
|
||||
| drive | 26 | drive | **25** | +4 | 🟡 S | 基本持平 |
|
||||
| apps | 63 | devapp | **25** | **+25** | 🔴 B | **helper 无 devapp 命令、25 个全由 shortcut 提供**(纯 gap-fill),但仍少于 lark |
|
||||
| sheets | 84 | sheet | **60** | +1 | 🔴 B | 钉钉表格 MCP 较少;helper 已覆盖,shortcut 曾重复、已 prune |
|
||||
| vc | 18 | — | 0 | — | 🔴 B | 钉钉 conference 无干净 MCP tool |
|
||||
| okr | 13 | — | 0 | — | 🔴 B | 钉钉无对应能力,**不可对齐** |
|
||||
| slides / markdown / whiteboard / note / event | 17 | — | 0 | — | 🔴 B | 钉钉无对应能力,**不可对齐** |
|
||||
|
||||
**dws 独有(lark 无对应服务)**:oa 审批(~20 tool) · attendance 考勤(~38) · report 日志(~7) · ding(~8) · aisearch/live/devdoc(helper 层提供)——钉钉工作流核心,构成差异化。
|
||||
|
||||
### GSB 汇总
|
||||
|
||||
- 🟢 **G(7 领域)**:im/mail/doc/minutes/calendar/contact/wiki——dws tool 覆盖更全。
|
||||
- 🟡 **S(3 领域)**:base/task/drive 量级持平(base 的持平**几乎全靠 shortcut 层 gap-fill**,helper 只有 16)。
|
||||
- 🔴 **B(受限 2 + 不可对齐 6)**:apps/sheets 少于 lark(sheets 受钉钉 API 限,apps 全由 shortcut 补但仍少);vc/okr/slides/markdown/whiteboard/note/event 客观不可对齐(非遗漏)。
|
||||
- **注**:这张表也印证了 1:1 层的真实价值分布——**base/apps 靠 shortcut 补了大量 helper 没有的 tool(gap-fill),而 mail/sheets 的 shortcut 基本是重复 helper(已 prune)**。
|
||||
|
||||
---
|
||||
|
||||
## 4. dws 差异化于 lark 的优势
|
||||
|
||||
### 4.1 执行底座:复用生产级 MCP 通道(架构性优势)
|
||||
lark-cli 每个 shortcut 直连飞书 SDK,错误处理/输出各自实现。dws shortcut 的 `CallMCP` **委托统一的 MCP 调用路径**,一步继承:
|
||||
- **错误分类**:auth 过期 / 未登录 / PAT / 业务错误,自动给出可执行提示;
|
||||
- **`--dry-run` 预览**:不发网络,输出将执行的 tool + 参数;
|
||||
- **`--format`/`--jq`/`--fields`**:机器可解析输出,Agent 友好。
|
||||
|
||||
lark 需在每个命令重复实现这些;dws 由框架统一注入,一致性与维护成本双赢。
|
||||
|
||||
### 4.2 覆盖更全的高频协作能力
|
||||
按 **dws tool 覆盖**(helper∪shortcut,§3 口径):chat 95 / mail 43 / doc 34 / minutes 25 / calendar 24 等即时协作场景,多为 lark 对应服务的 2–4 倍。
|
||||
|
||||
### 4.3 钉钉原生特有能力(lark 完全没有)
|
||||
审批 oa、日志 report、考勤 attendance、DING、企业智能搜索 aisearch —— 这些是钉钉工作流的核心,构成差异化护城河。
|
||||
|
||||
### 4.4 高频自动沉淀(P2,lark 无此设计)
|
||||
基于用户高频使用**主动把常用操作沉淀为自定义 shortcut**(`~/.dws/shortcuts/*.yaml` 运行时加载),让 CLI 越用越顺手。埋点**默认关(opt-in,`DWS_USAGE_TRACKING=1` 开启)**+开启后首跑告知,隐私优先(记形状不记值)。详见 [P2 设计](shortcut-p2-design.md)。**lark-cli 无任何等价能力。**
|
||||
|
||||
### 4.5 AI Agent 友好
|
||||
`--yes` 跳过确认、结构化错误、`--dry-run` 预览、`--print-schema`(规划中)——为 Agent 自动化调用而设计。
|
||||
|
||||
### 4.6 工程质量:全量自动化测试
|
||||
假 Caller 拦截,对全部 366 条(含写/删)做零副作用验证 + tool 名 ground truth 核验,可复跑、可回归。
|
||||
|
||||
---
|
||||
|
||||
## 5. 复盘与后续(loop 持续项)
|
||||
|
||||
### 5.0 关键复盘:1:1 层去冗余(511 → 298)
|
||||
**问题**:建 1:1 shortcut 层之前,**没有先摸清 `internal/helpers/` 已经把哪些 MCP tool 封装成了 `dws <svc> <verb>` 产品命令**。结果对 **213 个 tool 重复封装**——同一个 tool,helper 有 `dws contact user search`、我又造了 `dws contact +search-user`,且这批无投影增量,纯重复。
|
||||
**纠偏**:按「tool 已被 helper 封装 且 shortcut 用 CallMCP 无投影」精确删除 213 条,1:1 层 511 → **298**(保留 233 填空白 + 65 有投影),总数 579 → **366**,服务 19 → **16**(aisearch/live/devdoc 整包移除)。全绿、真机复验保留命令仍可用。
|
||||
**教训**:**做封装层前先审已有封装**。对齐 lark「每一条 shortcut」时,应先问「dws 这边是不是已经有等价命令了」,而不是无脑对齐。这是本项目最大的方法论盲点。
|
||||
|
||||
1. ~~补 apps(↔devapp)~~ ✅ 已完成:新增 30 个 devapp shortcut。
|
||||
2. **P2 落地**(差异化能力,lark 无):
|
||||
- ✅ **P2-1 usage 埋点**:装饰 MCP 调用唯一必经点记录 `~/.dws/usage.jsonl`(**记形状不记值**,敏感/自由文本字段脱敏;**默认关/opt-in**,`DWS_USAGE_TRACKING=1` 开启、开启后首跑告知)。端到端验证通过。
|
||||
- ✅ **stats/list**:`dws shortcut list [--service]`、`dws shortcut stats [--top N] [--purge]`(按 `(product,tool,arg_keys)` 聚合、识别固定值 fixed_args)。
|
||||
- ✅ **P2-2 suggest**:`dws shortcut suggest [--min N]` 把高频分组转成「建议沉淀的 +command」候选(含固定/可变参数拆分)。
|
||||
- ✅ **P2-3 YAML 自定义 shortcut 沉淀闭环**:`dws shortcut add` 写 `~/.dws/shortcuts/*.yaml` → 下次运行 `userdef.Load()` 编译成 Shortcut 注册(复用同一 runner;`${flag}` 绑定+常量;与内建冲突自动跳过)。**端到端验证通过**:add→重载→`dws <svc> +<cmd>` 可用,dry-run 组装正确。
|
||||
- ⏳ **P2-4 nudge**:命中高频候选时主动提示(TTY、频控、可永久关闭)。
|
||||
|
||||
> 至此,**「高频使用 → 建议 → 一键沉淀 → 自定义 shortcut 运行时生效」完整闭环已打通**——这是 lark-cli 完全没有的差异化能力。
|
||||
3. **深化 sheets**:随钉钉表格 MCP 能力增强补齐。
|
||||
4. **实机全读回归**:登录态下对全部只读 shortcut 做真实调用回归(需有效 token + 真实资源 ID)。
|
||||
5. **不可对齐项归档**:okr/slides/whiteboard/note/vc/event 明确标注为钉钉无能力,避免误解为遗漏。
|
||||
|
||||
---
|
||||
|
||||
## 附:一句话结论
|
||||
|
||||
> dws 已把钉钉侧**能对齐的主要服务全部对齐**(16 服务 / **366 shortcut** = 298 封装 + 68 智能编排),在即时协作能力上显著优于 lark,并拥有审批/考勤/日志/DING 等钉钉原生差异化能力与「高频自动沉淀」独有设计;受限项均为钉钉客观无对应能力,非工程遗漏。全部 366 条通过零副作用全量自动化验证。
|
||||
@@ -14,11 +14,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
|
||||
"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/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -28,6 +31,17 @@ func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller)
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
|
||||
// command tree, so distilled high-frequency operations mount alongside the
|
||||
// built-ins. Conflicts with built-ins are skipped inside Load.
|
||||
if _, err := userdef.Load(); err != nil {
|
||||
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
|
||||
}
|
||||
// Built-in + user shortcuts (`dws <service> +<command>`) share the same
|
||||
// command tree; mergeTopLevelCommands folds each shortcut's service parent
|
||||
// into the matching helper command so the `+leaf` sits alongside existing
|
||||
// subcommands.
|
||||
commands = append(commands, builtin.Commands()...)
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -346,7 +347,10 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
schemaCmd := newSchemaCommand(loader)
|
||||
mcpCmd := newMCPCommand(rootCtx, loader, runner, engine)
|
||||
mcpCmd.Hidden = true
|
||||
patCaller := newToolCallerAdapter(runner, flags)
|
||||
// Wrap the caller so every MCP tool call's shape is recorded to the local
|
||||
// usage log (privacy-preserving; see internal/shortcut/usage). Powers
|
||||
// `dws shortcut stats` and future high-frequency shortcut distillation.
|
||||
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(patCaller),
|
||||
@@ -364,6 +368,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
usage.NewShortcutCommand(),
|
||||
schemaCmd,
|
||||
mcpCmd,
|
||||
}
|
||||
|
||||
+11
-2
@@ -143,7 +143,6 @@ func newCommandRunnerWithFlags(loader cli.CatalogLoader, flags *GlobalFlags) exe
|
||||
scanner: newRuntimeContentScanner(),
|
||||
enforceContentScan: runtimeFlagEnabled(os.Getenv(runtimeContentScanEnforceEnv), false),
|
||||
includeScanReport: runtimeFlagEnabled(os.Getenv(runtimeContentScanReportOutputEnv), false),
|
||||
auditSink: setupAuditSink(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,6 +455,16 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
return r.executeStdioInvocation(ctx, invocation)
|
||||
}
|
||||
|
||||
// Constructing the Cobra tree is also used for help, schema, and command
|
||||
// discovery. Open the process-wide audit writer only when a real invocation
|
||||
// reaches the execution boundary so read-only command inspection does not
|
||||
// leave an audit lock handle behind (which prevents TempDir cleanup on
|
||||
// Windows). Keep an injected sink when tests or editions provide one.
|
||||
auditSink := r.auditSink
|
||||
if auditSink == nil {
|
||||
auditSink = setupAuditSink()
|
||||
}
|
||||
|
||||
invokeStart := time.Now()
|
||||
execID := generateExecutionID()
|
||||
r.transport.ExecutionId = execID
|
||||
@@ -483,7 +492,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
logging.LogCommandEnd(fl, execID,
|
||||
invocation.CanonicalProduct, invocation.Tool,
|
||||
retErr == nil, time.Since(invokeStart), errCat, errReason)
|
||||
emitAudit(r.auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
|
||||
emitAudit(auditSink, execID, invokeStart, invocation, endpoint, retErr, version)
|
||||
}()
|
||||
|
||||
// Check if this product has plugin-level auth credentials registered.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTodoListAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) {
|
||||
const (
|
||||
canonicalPath = "todo.list_todo_attachment"
|
||||
cliPath = "todo task list-attachment"
|
||||
)
|
||||
|
||||
root := NewRootCommand()
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil {
|
||||
t.Fatalf("executable command %q is missing", cliPath)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", cliPath, "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute embedded schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
|
||||
var tool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil {
|
||||
t.Fatalf("decode embedded schema leaf: %v", err)
|
||||
}
|
||||
if got := schemaContractString(tool["canonical_path"]); got != canonicalPath {
|
||||
t.Fatalf("canonical_path = %q, want %q", got, canonicalPath)
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != cliPath {
|
||||
t.Fatalf("primary_cli_path = %q, want %q", got, cliPath)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Fatalf("availability = %q, want available", got)
|
||||
}
|
||||
if problem := schemaHelpFlagCompletenessProblem(canonicalPath, cliPath, command, tool); problem != "" {
|
||||
t.Fatal(problem)
|
||||
}
|
||||
|
||||
taskID := schemaContractMap(tool["parameters"])["task-id"]
|
||||
if taskID == nil {
|
||||
t.Fatal("delivered Schema is missing --task-id")
|
||||
}
|
||||
if required, ok := taskID["required"].(bool); !ok || !required {
|
||||
t.Fatalf("task-id required = %#v, want true", taskID["required"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
// recordingToolCaller decorates a ToolCaller to record the SHAPE of every MCP
|
||||
// tool call into the local usage log (see internal/shortcut/usage). It is the
|
||||
// single chokepoint through which helper and shortcut commands dispatch, so one
|
||||
// wrapper captures all real usage. Recording never affects the call result and
|
||||
// is skipped for dry-run.
|
||||
type recordingToolCaller struct{ inner edition.ToolCaller }
|
||||
|
||||
func newRecordingToolCaller(inner edition.ToolCaller) edition.ToolCaller {
|
||||
return recordingToolCaller{inner: inner}
|
||||
}
|
||||
|
||||
func (r recordingToolCaller) CallTool(ctx context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
recordedArgs := cloneToolArgs(args)
|
||||
res, err := r.inner.CallTool(ctx, product, tool, args)
|
||||
usage.Append(product, tool, recordedArgs, err == nil, r.inner.DryRun())
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (r recordingToolCaller) Format() string { return r.inner.Format() }
|
||||
func (r recordingToolCaller) DryRun() bool { return r.inner.DryRun() }
|
||||
func (r recordingToolCaller) Fields() string { return r.inner.Fields() }
|
||||
func (r recordingToolCaller) JQ() string { return r.inner.JQ() }
|
||||
|
||||
func cloneToolArgs(args map[string]any) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(args))
|
||||
for k, v := range args {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/usage"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type crossPlatformCoverageCaller struct {
|
||||
args map[string]any
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *crossPlatformCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.args = args
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
func (*crossPlatformCoverageCaller) Format() string { return "json" }
|
||||
func (c *crossPlatformCoverageCaller) DryRun() bool { return c.dryRun }
|
||||
func (*crossPlatformCoverageCaller) Fields() string { return "id,name" }
|
||||
func (*crossPlatformCoverageCaller) JQ() string { return ".result" }
|
||||
|
||||
func TestCrossPlatformCoverageCloneToolArgsDefensiveCopy(t *testing.T) {
|
||||
args := map[string]any{"page": 1, "query": "keep"}
|
||||
cloned := cloneToolArgs(args)
|
||||
args["page"] = 2
|
||||
args["extra"] = true
|
||||
|
||||
if got := cloned["page"]; got != 1 {
|
||||
t.Fatalf("cloned page = %#v, want 1", got)
|
||||
}
|
||||
if _, ok := cloned["extra"]; ok {
|
||||
t.Fatal("clone changed after source map mutation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCloneToolArgsEmpty(t *testing.T) {
|
||||
if got := cloneToolArgs(nil); got != nil {
|
||||
t.Fatalf("nil clone = %#v, want nil", got)
|
||||
}
|
||||
if got := cloneToolArgs(map[string]any{}); got != nil {
|
||||
t.Fatalf("empty clone = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordingToolCaller(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("DWS_USAGE_TRACKING", "1")
|
||||
|
||||
inner := &crossPlatformCoverageCaller{}
|
||||
caller := newRecordingToolCaller(inner)
|
||||
args := map[string]any{"open_conversation_id": "cid_x", "text": "private"}
|
||||
if _, err := caller.CallTool(context.Background(), "chat", "send_message", args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inner.args["open_conversation_id"] != "cid_x" {
|
||||
t.Fatalf("forwarded args = %#v", inner.args)
|
||||
}
|
||||
if caller.Format() != "json" || caller.Fields() != "id,name" || caller.JQ() != ".result" || caller.DryRun() {
|
||||
t.Fatal("recording caller did not delegate output settings")
|
||||
}
|
||||
records, err := usage.Read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 1 || records[0].Product != "chat" || records[0].Tool != "send_message" {
|
||||
t.Fatalf("usage records = %#v", records)
|
||||
}
|
||||
if _, leaked := records[0].SampleArgs["text"]; leaked {
|
||||
t.Fatal("sensitive text must not be recorded")
|
||||
}
|
||||
|
||||
inner.dryRun = true
|
||||
if _, err := caller.CallTool(context.Background(), "chat", "send_message", args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
records, err = usage.Read()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("dry-run call must not be recorded: %#v", records)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRootPublishesShortcutCommands(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
root := NewRootCommand(context.Background())
|
||||
for _, path := range [][]string{{"shortcut", "list"}, {"calendar", "+today"}} {
|
||||
cmd, remaining, err := root.Find(path)
|
||||
if err != nil || len(remaining) != 0 || cmd == nil {
|
||||
t.Fatalf("root.Find(%v) = cmd=%v remaining=%v err=%v", path, cmd, remaining, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(configDir, "audit", ".audit.lock")); !os.IsNotExist(err) {
|
||||
t.Fatalf("constructing the root command opened an audit lock: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -127,6 +128,9 @@ func TestAcquireTokenLock_Contention(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAcquireTokenLock_LockFilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows enforces file access through ACLs, not POSIX mode bits")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
configDir := t.TempDir()
|
||||
|
||||
@@ -61,6 +61,7 @@ func TestExportPortableAuthBundleRequiresAuthToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPortableAuthTargetPopulated(t *testing.T) {
|
||||
requirePortableFileBackend(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, ".dws")
|
||||
@@ -82,6 +83,7 @@ func TestPortableAuthTargetPopulated(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
|
||||
requirePortableFileBackend(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
|
||||
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
|
||||
@@ -140,6 +142,7 @@ func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
|
||||
requirePortableFileBackend(t)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
SetRuntimeProfile("")
|
||||
t.Cleanup(func() { SetRuntimeProfile("") })
|
||||
@@ -211,3 +214,10 @@ func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
|
||||
t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func requirePortableFileBackend(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("portable bundle round trips require a file-DEK backend; Windows uses DPAPI registry storage")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,13 +99,15 @@ func SaveSecureTokenData(configDir string, data *TokenData) error {
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(configDir, secureDataFile)
|
||||
tmpPath := finalPath + ".tmp"
|
||||
|
||||
// Atomic write with fsync to ensure data durability
|
||||
tmpFile, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, config.FilePerm)
|
||||
// Give every writer its own temporary file. Reusing one fixed .tmp path lets
|
||||
// concurrent saves truncate or rename another writer's ciphertext before it
|
||||
// is complete, which can publish a corrupt final file.
|
||||
tmpFile, err := os.CreateTemp(configDir, secureDataFile+".tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating tmp file: %w", err)
|
||||
}
|
||||
tmpPath := tmpFile.Name()
|
||||
|
||||
writeSuccess := false
|
||||
defer func() {
|
||||
@@ -172,7 +174,13 @@ func DeleteSecureData(configDir string) error {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("deleting secure data file: %w", err)
|
||||
}
|
||||
// Remove the legacy fixed temporary path and any per-writer temporary files
|
||||
// left behind by an interrupted save.
|
||||
_ = os.Remove(path + ".tmp")
|
||||
tmpPaths, _ := filepath.Glob(path + ".tmp-*")
|
||||
for _, tmpPath := range tmpPaths {
|
||||
_ = os.Remove(tmpPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,16 @@ package auth
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSaveSecureTokenData_FixesUnsafePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows enforces directory access through ACLs, not POSIX mode bits")
|
||||
}
|
||||
configDir := filepath.Join(t.TempDir(), "unsafe")
|
||||
// Create directory with overly permissive mode.
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
@@ -101,9 +105,12 @@ func TestSaveSecureTokenData_TmpFileCleanedOnSuccess(t *testing.T) {
|
||||
t.Fatalf("SaveSecureTokenData() error = %v", err)
|
||||
}
|
||||
|
||||
tmpPath := filepath.Join(configDir, secureDataFile+".tmp")
|
||||
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
|
||||
t.Fatalf(".data.tmp should not remain after successful save, stat err = %v", err)
|
||||
tmpPaths, err := filepath.Glob(filepath.Join(configDir, secureDataFile+".tmp-*"))
|
||||
if err != nil {
|
||||
t.Fatalf("Glob() error = %v", err)
|
||||
}
|
||||
if len(tmpPaths) != 0 {
|
||||
t.Fatalf("temporary files should not remain after successful save: %v", tmpPaths)
|
||||
}
|
||||
|
||||
// The final file must exist.
|
||||
@@ -136,9 +143,9 @@ func TestSaveSecureTokenData_ConcurrentSaves(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Under concurrency, some saves may fail due to tmp-file races. That is
|
||||
// acceptable — the important thing is that at least one succeeds and the
|
||||
// final file is not corrupted.
|
||||
// Each writer owns its temporary file, so concurrent saves must not corrupt
|
||||
// the final file. A platform may still reject simultaneous replacements, but
|
||||
// at least one complete save must succeed.
|
||||
successes := 0
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
|
||||
@@ -29017,6 +29017,288 @@
|
||||
"用户要移动字段/调整列顺序时必须走本命令"
|
||||
]
|
||||
},
|
||||
"aitable workflow create": {
|
||||
"agent_summary": "创建并发布 AI 表格自动化工作流。",
|
||||
"agent_summary_source": "dws-agent-selection/aitable",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"已有工作流要修改时用 workflow update;仅启停已有工作流用 enable/disable;请求结果不确定时先 list 核对,避免非幂等重复创建"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "write",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws aitable workflow create --base-id \u003cBASE_ID\u003e --dsl @workflow.json --locale zh-CN"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "创建并发布 AI 表格自动化工作流。",
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "创建并发布 AI 表格自动化工作流。",
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"已有工作流要修改时用 workflow update;仅启停已有工作流用 enable/disable;请求结果不确定时先 list 核对,避免非幂等重复创建"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"已有工作流要修改时用 workflow update;仅启停已有工作流用 enable/disable;请求结果不确定时先 list 核对,避免非幂等重复创建"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
|
||||
},
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "write",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "write",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws aitable workflow create --base-id \u003cBASE_ID\u003e --dsl @workflow.json --locale zh-CN"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws aitable workflow create --base-id \u003cBASE_ID\u003e --dsl @workflow.json --locale zh-CN"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "non_idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "non_idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
|
||||
},
|
||||
{
|
||||
"value": "unknown",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "medium",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "medium",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional."
|
||||
},
|
||||
{
|
||||
"value": "medium",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"用户明确要求在已知 Base 中创建自动化,且已准备完整 workflow-dsl/v1 定义时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"用户明确要求在已知 Base 中创建自动化,且已准备完整 workflow-dsl/v1 定义时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"risk": "medium",
|
||||
"source_refs": [
|
||||
"cobra-help:dws aitable workflow create --help",
|
||||
"internal/cli/schema_command_registry.json#aitable.workflow_create",
|
||||
"internal/cli/schema_hints/metadata/aitable.json",
|
||||
"internal/cli/schema_hints/selection/aitable.json",
|
||||
"skills/mono/references/products/aitable/aitable-workflow.md"
|
||||
],
|
||||
"use_when": [
|
||||
"用户明确要求在已知 Base 中创建自动化,且已准备完整 workflow-dsl/v1 定义时"
|
||||
]
|
||||
},
|
||||
"aitable workflow disable": {
|
||||
"agent_summary": "禁用工作流(高危,需确认;status 变 STOP)。",
|
||||
"agent_summary_source": "dws-agent-selection/aitable",
|
||||
@@ -30072,6 +30354,288 @@
|
||||
"use_when": [
|
||||
"查看有哪些工作流及 status/flowId 时"
|
||||
]
|
||||
},
|
||||
"aitable workflow update": {
|
||||
"agent_summary": "用完整 DSL 更新并发布已有 AI 表格自动化工作流。",
|
||||
"agent_summary_source": "dws-agent-selection/aitable",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"新建工作流用 create;只改变运行状态用 enable/disable;不要把局部 patch 或 workflow get 返回的 flowSchema 直接当作 DSL"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "write",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws aitable workflow update --base-id \u003cBASE_ID\u003e --workflow-id \u003cFLOW_ID\u003e --dsl @workflow.json --locale zh-CN"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "用完整 DSL 更新并发布已有 AI 表格自动化工作流。",
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "用完整 DSL 更新并发布已有 AI 表格自动化工作流。",
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"新建工作流用 create;只改变运行状态用 enable/disable;不要把局部 patch 或 workflow get 返回的 flowSchema 直接当作 DSL"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"新建工作流用 create;只改变运行状态用 enable/disable;不要把局部 patch 或 workflow get 返回的 flowSchema 直接当作 DSL"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
|
||||
},
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "write",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "write",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws aitable workflow update --base-id \u003cBASE_ID\u003e --workflow-id \u003cFLOW_ID\u003e --dsl @workflow.json --locale zh-CN"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws aitable workflow update --base-id \u003cBASE_ID\u003e --workflow-id \u003cFLOW_ID\u003e --dsl @workflow.json --locale zh-CN"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "unknown",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "unknown",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
|
||||
},
|
||||
{
|
||||
"value": "unknown",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "medium",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "medium",
|
||||
"source": "internal/cli/schema_hints/metadata/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional."
|
||||
},
|
||||
{
|
||||
"value": "medium",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"用户明确要求修改已知工作流,且已先留底当前详情并准备完整 workflow-dsl/v1 目标定义时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"用户明确要求修改已知工作流,且已先留底当前详情并准备完整 workflow-dsl/v1 目标定义时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/aitable.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"risk": "medium",
|
||||
"source_refs": [
|
||||
"cobra-help:dws aitable workflow update --help",
|
||||
"internal/cli/schema_command_registry.json#aitable.workflow_update",
|
||||
"internal/cli/schema_hints/metadata/aitable.json",
|
||||
"internal/cli/schema_hints/selection/aitable.json",
|
||||
"skills/mono/references/products/aitable/aitable-workflow.md"
|
||||
],
|
||||
"use_when": [
|
||||
"用户明确要求修改已知工作流,且已先留底当前详情并准备完整 workflow-dsl/v1 目标定义时"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:b42825642a99ec968d421660de7bab9de4b756d81906f1df2e1301865760a24f",
|
||||
"surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3",
|
||||
"source_hash": "sha256:d398f66780f4959874c4ffc48be5be08c716ba2daeed8ae3d8690504846a3906",
|
||||
"surface_hash": "sha256:631b73b174f48cb0cca7c8449e80920739010d0db223dac2855101f4dbd0eda6",
|
||||
"coverage": {
|
||||
"surface_products": 22,
|
||||
"products_with_metadata": 22,
|
||||
"surface_tools": 564,
|
||||
"tools_with_metadata": 564,
|
||||
"tools_with_agent_summary": 564,
|
||||
"tools_with_use_when": 564,
|
||||
"tools_with_avoid_when": 564,
|
||||
"tools_with_examples": 564,
|
||||
"tools_with_interface_mode": 564,
|
||||
"unmatched_skill_tools": 125,
|
||||
"surface_tools": 569,
|
||||
"tools_with_metadata": 569,
|
||||
"tools_with_agent_summary": 569,
|
||||
"tools_with_use_when": 569,
|
||||
"tools_with_avoid_when": 569,
|
||||
"tools_with_examples": 569,
|
||||
"tools_with_interface_mode": 569,
|
||||
"unmatched_skill_tools": 124,
|
||||
"unreviewed_skill_tools": 7
|
||||
},
|
||||
"products": {
|
||||
@@ -1371,20 +1371,21 @@
|
||||
]
|
||||
},
|
||||
"sheet": {
|
||||
"agent_summary": "创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
|
||||
"agent_summary": "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
|
||||
"agent_summary_source": "dws-agent-selection/sheet",
|
||||
"avoid_when": [
|
||||
"目标是 AI 表格 Base 的结构化记录、钉钉文档正文或本地 Excel 文件时不要使用 sheet"
|
||||
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
|
||||
"只读取已上传但未转换的 xlsx/xls 节点时先用 doc download;不要把文件节点直接传给单元格命令"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
|
||||
"value": "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
|
||||
"value": "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true
|
||||
@@ -1393,7 +1394,8 @@
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"目标是 AI 表格 Base 的结构化记录、钉钉文档正文或本地 Excel 文件时不要使用 sheet"
|
||||
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
|
||||
"只读取已上传但未转换的 xlsx/xls 节点时先用 doc download;不要把文件节点直接传给单元格命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -1401,7 +1403,8 @@
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"目标是 AI 表格 Base 的结构化记录、钉钉文档正文或本地 Excel 文件时不要使用 sheet"
|
||||
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
|
||||
"只读取已上传但未转换的 xlsx/xls 节点时先用 doc download;不要把文件节点直接传给单元格命令"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -1411,7 +1414,8 @@
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时"
|
||||
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
|
||||
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -1419,7 +1423,8 @@
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时"
|
||||
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
|
||||
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
@@ -1440,7 +1445,8 @@
|
||||
"skills/mono/references/products/sheet.md"
|
||||
],
|
||||
"use_when": [
|
||||
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时"
|
||||
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
|
||||
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时"
|
||||
]
|
||||
},
|
||||
"todo": {
|
||||
|
||||
@@ -11251,6 +11251,581 @@
|
||||
"需要关闭指定工作表的网格线显示时"
|
||||
]
|
||||
},
|
||||
"sheet import create": {
|
||||
"agent_summary": "将本地 xlsx/xls 文件导入为新的钉钉在线电子表格。",
|
||||
"agent_summary_source": "dws-agent-selection/sheet",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"读取或修改已有在线表格时应使用 sheet list/info/range 等命令",
|
||||
"查询已提交导入任务的状态时使用 sheet import get,不要重复创建导入任务"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "write",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws sheet import create --file ./report.xlsx --folder-token \u003cFOLDER_TOKEN\u003e --format json",
|
||||
"dws sheet import create --file ./data.xls --workspace \u003cWORKSPACE_ID\u003e --name \"月度报表\" --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "将本地 xlsx/xls 文件导入为新的钉钉在线电子表格。",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "将本地 xlsx/xls 文件导入为新的钉钉在线电子表格。",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"读取或修改已有在线表格时应使用 sheet list/info/range 等命令",
|
||||
"查询已提交导入任务的状态时使用 sheet import get,不要重复创建导入任务"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"读取或修改已有在线表格时应使用 sheet list/info/range 等命令",
|
||||
"查询已提交导入任务的状态时使用 sheet import get,不要重复创建导入任务"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
|
||||
},
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "write",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "write",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws sheet import create --file ./report.xlsx --folder-token \u003cFOLDER_TOKEN\u003e --format json",
|
||||
"dws sheet import create --file ./data.xls --workspace \u003cWORKSPACE_ID\u003e --name \"月度报表\" --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws sheet import create --file ./report.xlsx --folder-token \u003cFOLDER_TOKEN\u003e --format json",
|
||||
"dws sheet import create --file ./data.xls --workspace \u003cWORKSPACE_ID\u003e --name \"月度报表\" --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "non_idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "non_idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
|
||||
},
|
||||
{
|
||||
"value": "unknown",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "该 CLI 导入流程包含本地文件校验、创建导入会话、OSS 上传、确认转换和任务轮询,不能绑定为单一 interface_ref。",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "该 CLI 导入流程包含本地文件校验、创建导入会话、OSS 上传、确认转换和任务轮询,不能绑定为单一 interface_ref。",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "medium",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "medium",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
|
||||
},
|
||||
{
|
||||
"value": "medium",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"用户要把本地 Excel 文件转换为可在线编辑的钉钉表格时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"用户要把本地 Excel 文件转换为可在线编辑的钉钉表格时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "该 CLI 导入流程包含本地文件校验、创建导入会话、OSS 上传、确认转换和任务轮询,不能绑定为单一 interface_ref。",
|
||||
"reviewed": true,
|
||||
"risk": "medium",
|
||||
"source_refs": [
|
||||
"cobra-help:dws sheet import create --help",
|
||||
"internal/cli/schema_command_registry.json",
|
||||
"internal/cli/schema_hints/metadata/sheet.json",
|
||||
"internal/cli/schema_hints/selection/sheet.json",
|
||||
"live-mcp:none (Cobra+Skill; composite/unpinned)",
|
||||
"skills/mono/references/products/sheet/sheet-import.md"
|
||||
],
|
||||
"use_when": [
|
||||
"用户要把本地 Excel 文件转换为可在线编辑的钉钉表格时"
|
||||
]
|
||||
},
|
||||
"sheet import get": {
|
||||
"agent_summary": "根据 taskId 查询表格导入任务结果。",
|
||||
"agent_summary_source": "dws-agent-selection/sheet",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"发起新的 xlsx/xls 导入应使用 sheet import;不要用本命令代替导入",
|
||||
"状态仍为 processing 时不要重新提交 import,稍后继续查询同一 taskId"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "agent-hint",
|
||||
"examples": [
|
||||
"dws sheet import get --task-id \u003cTASK_ID\u003e --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "根据 taskId 查询表格导入任务结果。",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "根据 taskId 查询表格导入任务结果。",
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"发起新的 xlsx/xls 导入应使用 sheet import;不要用本命令代替导入",
|
||||
"状态仍为 processing 时不要重新提交 import,稍后继续查询同一 taskId"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"发起新的 xlsx/xls 导入应使用 sheet import;不要用本命令代替导入",
|
||||
"状态仍为 processing 时不要重新提交 import,稍后继续查询同一 taskId"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
|
||||
},
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
|
||||
},
|
||||
{
|
||||
"value": "write",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws sheet import get --task-id \u003cTASK_ID\u003e --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws sheet import get --task-id \u003cTASK_ID\u003e --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
|
||||
},
|
||||
{
|
||||
"value": "unknown",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "该 CLI 续查命令显式复用 doc server 的 query_import_task;当前 Sheet 静态接口快照没有可直接绑定的单一 interface_ref。",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "该 CLI 续查命令显式复用 doc server 的 query_import_task;当前 Sheet 静态接口快照没有可直接绑定的单一 interface_ref。",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "internal/cli/schema_hints/metadata/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
|
||||
},
|
||||
{
|
||||
"value": "medium",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"已有 sheet import 返回的 taskId,需要在导入超时或中断后续查转换状态时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"已有 sheet import 返回的 taskId,需要在导入超时或中断后续查转换状态时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/sheet.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "该 CLI 续查命令显式复用 doc server 的 query_import_task;当前 Sheet 静态接口快照没有可直接绑定的单一 interface_ref。",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"cobra-help:dws sheet import get --help",
|
||||
"internal/cli/schema_command_registry.json",
|
||||
"internal/cli/schema_hints/metadata/sheet.json",
|
||||
"internal/cli/schema_hints/selection/sheet.json",
|
||||
"live-mcp:none (Cobra+Skill; composite/unpinned)",
|
||||
"skills/mono/references/products/sheet/sheet-import.md"
|
||||
],
|
||||
"use_when": [
|
||||
"已有 sheet import 返回的 taskId,需要在导入超时或中断后续查转换状态时"
|
||||
]
|
||||
},
|
||||
"sheet info": {
|
||||
"agent_summary": "获取指定工作表详情(行列数、非空范围、合并区等)。",
|
||||
"agent_summary_source": "dws-agent-selection/sheet",
|
||||
|
||||
@@ -3439,6 +3439,265 @@
|
||||
"需要按完成状态、优先级、角色或截止日期范围查询当前用户待办列表时"
|
||||
]
|
||||
},
|
||||
"todo task list-attachment": {
|
||||
"agent_summary": "查询待办附件列表",
|
||||
"agent_summary_source": "dws-agent-selection/todo",
|
||||
"availability": "available",
|
||||
"avoid_when": [
|
||||
"要上传附件时改用 dws todo task add-attachment",
|
||||
"要删除附件时先用本命令确认 attachmentId,再改用 dws todo task remove-attachment"
|
||||
],
|
||||
"confirmation": "not_required",
|
||||
"effect": "read",
|
||||
"effect_source": "command-verb",
|
||||
"examples": [
|
||||
"dws todo task list-attachment --task-id \u003ctaskId\u003e",
|
||||
"dws todo task list-attachment --task-id \u003ctaskId\u003e --format json"
|
||||
],
|
||||
"field_provenance": {
|
||||
"agent_summary": {
|
||||
"value": "查询待办附件列表",
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据当前 Cobra Help、Todo Skill 与 helper 中的 list_todo_attachment 调用手工审阅;当前 live leaf Schema 缺失,记录失败证据并以源码契约收口。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "查询待办附件列表",
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据当前 Cobra Help、Todo Skill 与 helper 中的 list_todo_attachment 调用手工审阅;当前 live leaf Schema 缺失,记录失败证据并以源码契约收口。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"availability": {
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "available",
|
||||
"source": "internal/cli/schema_hints/metadata/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"avoid_when": {
|
||||
"value": [
|
||||
"要上传附件时改用 dws todo task add-attachment",
|
||||
"要删除附件时先用本命令确认 attachmentId,再改用 dws todo task remove-attachment"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据当前 Cobra Help、Todo Skill 与 helper 中的 list_todo_attachment 调用手工审阅;当前 live leaf Schema 缺失,记录失败证据并以源码契约收口。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"要上传附件时改用 dws todo task add-attachment",
|
||||
"要删除附件时先用本命令确认 attachmentId,再改用 dws todo task remove-attachment"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据当前 Cobra Help、Todo Skill 与 helper 中的 list_todo_attachment 调用手工审阅;当前 live leaf Schema 缺失,记录失败证据并以源码契约收口。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"confirmation": {
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "not_required",
|
||||
"source": "risk-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"effect": {
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "read",
|
||||
"source": "command-verb",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"examples": {
|
||||
"value": [
|
||||
"dws todo task list-attachment --task-id \u003ctaskId\u003e",
|
||||
"dws todo task list-attachment --task-id \u003ctaskId\u003e --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据当前 Cobra Help、Todo Skill 与 helper 中的 list_todo_attachment 调用手工审阅;当前 live leaf Schema 缺失,记录失败证据并以源码契约收口。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"dws todo task list-attachment --task-id \u003ctaskId\u003e",
|
||||
"dws todo task list-attachment --task-id \u003ctaskId\u003e --format json"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据当前 Cobra Help、Todo Skill 与 helper 中的 list_todo_attachment 调用手工审阅;当前 live leaf Schema 缺失,记录失败证据并以源码契约收口。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"idempotency": {
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "idempotent",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_mode": {
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "composite",
|
||||
"source": "internal/cli/schema_hints/metadata/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_reason": {
|
||||
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"source": "internal/cli/schema_hints/metadata/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"source": "internal/cli/schema_hints/metadata/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed"
|
||||
}
|
||||
]
|
||||
},
|
||||
"interface_ref": {
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "interface_disposition_matrix",
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref",
|
||||
"candidates": [
|
||||
{
|
||||
"value": null,
|
||||
"source": "internal/cli/schema_hints/metadata/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "final interface mode composite forbids a direct MCP interface_ref"
|
||||
}
|
||||
]
|
||||
},
|
||||
"reviewed": {
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed",
|
||||
"candidates": [
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/metadata/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed"
|
||||
},
|
||||
{
|
||||
"value": true,
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": false,
|
||||
"review_reason": "依据当前 Cobra Help、Todo Skill 与 helper 中的 list_todo_attachment 调用手工审阅;当前 live leaf Schema 缺失,记录失败证据并以源码契约收口。"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risk": {
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"resolution": "highest_precedence",
|
||||
"candidates": [
|
||||
{
|
||||
"value": "low",
|
||||
"source": "effect-default",
|
||||
"precedence": "inference_or_default",
|
||||
"selected": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"use_when": {
|
||||
"value": [
|
||||
"已知 taskId,需要查看该待办当前附件及其 attachmentId、文件名和大小时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"resolution": "highest_precedence",
|
||||
"review_reason": "依据当前 Cobra Help、Todo Skill 与 helper 中的 list_todo_attachment 调用手工审阅;当前 live leaf Schema 缺失,记录失败证据并以源码契约收口。",
|
||||
"candidates": [
|
||||
{
|
||||
"value": [
|
||||
"已知 taskId,需要查看该待办当前附件及其 attachmentId、文件名和大小时"
|
||||
],
|
||||
"source": "internal/cli/schema_hints/selection/todo.json",
|
||||
"precedence": "reviewed_explicit",
|
||||
"selected": true,
|
||||
"review_reason": "依据当前 Cobra Help、Todo Skill 与 helper 中的 list_todo_attachment 调用手工审阅;当前 live leaf Schema 缺失,记录失败证据并以源码契约收口。"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"risk": "low",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=todo.list_todo_attachment",
|
||||
"Skill:skills/mono/references/products/todo.md",
|
||||
"cobra-help:dws todo task list-attachment",
|
||||
"internal/cli/schema_hints/metadata/todo.json",
|
||||
"internal/cli/schema_hints/selection/todo.json",
|
||||
"live-dws-schema:todo.list_todo_attachment#FAILED",
|
||||
"runtime-call:internal/helpers/todo.go#list_todo_attachment",
|
||||
"skills/mono/references/products/todo.md"
|
||||
],
|
||||
"use_when": [
|
||||
"已知 taskId,需要查看该待办当前附件及其 attachmentId、文件名和大小时"
|
||||
]
|
||||
},
|
||||
"todo task remove-executor": {
|
||||
"agent_summary": "移除待办执行人",
|
||||
"agent_summary_source": "dws-agent-selection/todo",
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": 1,
|
||||
"source_hash": "sha256:b42825642a99ec968d421660de7bab9de4b756d81906f1df2e1301865760a24f",
|
||||
"surface_hash": "sha256:e858be68701d256818eccd085a6f7f97655835b9c7cbbce6275ed930c2436dc3",
|
||||
"source_files": 149,
|
||||
"source_hash": "sha256:d398f66780f4959874c4ffc48be5be08c716ba2daeed8ae3d8690504846a3906",
|
||||
"surface_hash": "sha256:631b73b174f48cb0cca7c8449e80920739010d0db223dac2855101f4dbd0eda6",
|
||||
"source_files": 150,
|
||||
"hint_files": 46,
|
||||
"hint_products": 39,
|
||||
"hint_tools": 1280,
|
||||
"hint_tools": 1290,
|
||||
"interface_metadata": {
|
||||
"source": "mcp-tools-list+cli-registry",
|
||||
"revision": "4574f7022c32cf4c033e9b7b4156e2fec815fed8",
|
||||
@@ -29,14 +29,14 @@
|
||||
"coverage": {
|
||||
"surface_products": 22,
|
||||
"products_with_metadata": 22,
|
||||
"surface_tools": 564,
|
||||
"tools_with_metadata": 564,
|
||||
"tools_with_agent_summary": 564,
|
||||
"tools_with_use_when": 564,
|
||||
"tools_with_avoid_when": 564,
|
||||
"tools_with_examples": 564,
|
||||
"tools_with_interface_mode": 564,
|
||||
"unmatched_skill_tools": 125,
|
||||
"surface_tools": 569,
|
||||
"tools_with_metadata": 569,
|
||||
"tools_with_agent_summary": 569,
|
||||
"tools_with_use_when": 569,
|
||||
"tools_with_avoid_when": 569,
|
||||
"tools_with_examples": 569,
|
||||
"tools_with_interface_mode": 569,
|
||||
"unmatched_skill_tools": 124,
|
||||
"unreviewed_skill_tools": 7
|
||||
},
|
||||
"source_products": [
|
||||
@@ -160,7 +160,7 @@
|
||||
{
|
||||
"tool_path": "calendar participant delete",
|
||||
"source": "skills/mono/SKILL.md",
|
||||
"line": 99,
|
||||
"line": 135,
|
||||
"candidates": [
|
||||
"calendar attendee delete",
|
||||
"calendar event delete",
|
||||
@@ -3877,7 +3877,7 @@
|
||||
{
|
||||
"tool_path": "sheet range",
|
||||
"source": "skills/mono/references/products/sheet.md",
|
||||
"line": 65,
|
||||
"line": 66,
|
||||
"candidates": [
|
||||
"sheet range batch-clear",
|
||||
"sheet range batch-set-style",
|
||||
@@ -3891,7 +3891,7 @@
|
||||
{
|
||||
"tool_path": "sheet filter-view",
|
||||
"source": "skills/mono/references/products/sheet.md",
|
||||
"line": 66,
|
||||
"line": 67,
|
||||
"candidates": [
|
||||
"sheet filter-view create",
|
||||
"sheet filter-view delete",
|
||||
@@ -3944,20 +3944,6 @@
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_path": "todo task list-attachment",
|
||||
"source": "skills/mono/references/products/todo.md",
|
||||
"line": 216,
|
||||
"candidates": [
|
||||
"todo task add-attachment",
|
||||
"todo task add-executor",
|
||||
"todo task add-participant"
|
||||
],
|
||||
"review": {
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_path": "todo task remove-attachment",
|
||||
"source": "skills/mono/references/products/todo.md",
|
||||
@@ -3986,20 +3972,6 @@
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_path": "todo task list-attachment",
|
||||
"source": "skills/mono/references/products/todo.md",
|
||||
"line": 375,
|
||||
"candidates": [
|
||||
"todo task add-attachment",
|
||||
"todo task add-executor",
|
||||
"todo task add-participant"
|
||||
],
|
||||
"review": {
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
}
|
||||
},
|
||||
{
|
||||
"tool_path": "todo task remove-attachment",
|
||||
"source": "skills/mono/references/products/todo.md",
|
||||
|
||||
+3208
-25
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,223 @@
|
||||
"version"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "shortcut-catalog-surface",
|
||||
"reason": "Shortcut commands are high-level task entries discovered through dws shortcut list and product skills; they are intentionally separate from the runtime Schema catalog of base Agent tools.",
|
||||
"reviewed": true,
|
||||
"commands": [
|
||||
"aitable +base-get",
|
||||
"aitable +base-list",
|
||||
"aitable +base-search",
|
||||
"aitable +chart-get",
|
||||
"aitable +chart-widgets-example",
|
||||
"aitable +dashboard-config-example",
|
||||
"aitable +dashboard-get",
|
||||
"aitable +field-get",
|
||||
"aitable +find-record",
|
||||
"aitable +form-field-list",
|
||||
"aitable +form-list",
|
||||
"aitable +form-share-get",
|
||||
"aitable +list-tables",
|
||||
"aitable +record-history-list",
|
||||
"aitable +record-query",
|
||||
"aitable +record-query-empty",
|
||||
"aitable +record-share-links",
|
||||
"aitable +record-share-url",
|
||||
"aitable +resolve-base",
|
||||
"aitable +resolve-table",
|
||||
"aitable +role-list",
|
||||
"aitable +section-list-empty",
|
||||
"aitable +section-list-nodes",
|
||||
"aitable +table-get",
|
||||
"aitable +template-search",
|
||||
"aitable +view-get",
|
||||
"aitable +view-get-frozen-cols",
|
||||
"aitable +view-get-lock",
|
||||
"aitable +view-get-row-height",
|
||||
"attendance +check-record",
|
||||
"attendance +check-result",
|
||||
"attendance +get-adjustment-rule",
|
||||
"attendance +get-approve-template",
|
||||
"attendance +get-checkin-record",
|
||||
"attendance +get-leave-records",
|
||||
"attendance +get-overtime-rule",
|
||||
"attendance +get-schedule",
|
||||
"attendance +get-self-setting",
|
||||
"attendance +get-summary",
|
||||
"attendance +list-approve",
|
||||
"attendance +list-leave-types",
|
||||
"attendance +my-attendance",
|
||||
"attendance +query-report-data",
|
||||
"attendance +search-adjustment-rule",
|
||||
"attendance +search-class",
|
||||
"attendance +search-group",
|
||||
"attendance +search-overtime-rule",
|
||||
"attendance +this-month",
|
||||
"calendar +agenda",
|
||||
"calendar +attendee-list",
|
||||
"calendar +book",
|
||||
"calendar +book-list",
|
||||
"calendar +book-search",
|
||||
"calendar +cancel-event",
|
||||
"calendar +conflicts",
|
||||
"calendar +free",
|
||||
"calendar +free-slots",
|
||||
"calendar +freebusy",
|
||||
"calendar +invite",
|
||||
"calendar +my-free",
|
||||
"calendar +next-event",
|
||||
"calendar +reschedule",
|
||||
"calendar +room-groups",
|
||||
"calendar +room-search",
|
||||
"calendar +suggest-time",
|
||||
"calendar +today",
|
||||
"calendar +tomorrow",
|
||||
"calendar +week",
|
||||
"chat +at-me",
|
||||
"chat +bot-find",
|
||||
"chat +bot-search",
|
||||
"chat +broadcast",
|
||||
"chat +category-create",
|
||||
"chat +category-delete",
|
||||
"chat +category-list",
|
||||
"chat +category-rename",
|
||||
"chat +chat-bots",
|
||||
"chat +chat-dismiss",
|
||||
"chat +chat-invite-url",
|
||||
"chat +chat-list-all",
|
||||
"chat +chat-list-join-requests",
|
||||
"chat +chat-list-mine",
|
||||
"chat +chat-mute",
|
||||
"chat +chat-role-add",
|
||||
"chat +chat-role-list",
|
||||
"chat +chat-role-query-user",
|
||||
"chat +chat-role-set-user",
|
||||
"chat +chat-role-update",
|
||||
"chat +chat-search",
|
||||
"chat +chat-set-admin",
|
||||
"chat +chat-set-history",
|
||||
"chat +chat-update-alias",
|
||||
"chat +chat-update-nick",
|
||||
"chat +conversation-clear-all-red-point",
|
||||
"chat +conversation-info",
|
||||
"chat +conversation-list",
|
||||
"chat +conversation-list-top",
|
||||
"chat +dm",
|
||||
"chat +group-members",
|
||||
"chat +messages-list-direct",
|
||||
"chat +messages-list-pin",
|
||||
"chat +messages-list-unread-conversations",
|
||||
"chat +messages-mget",
|
||||
"chat +messages-query-send-status",
|
||||
"chat +messages-read-status",
|
||||
"chat +messages-send-by-webhook",
|
||||
"chat +messages-update-card",
|
||||
"chat +my-groups",
|
||||
"chat +send-to-group",
|
||||
"chat +unread-chats",
|
||||
"contact +by-mobile",
|
||||
"contact +dept-members",
|
||||
"contact +list-dept-members",
|
||||
"contact +list-followings",
|
||||
"contact +list-role-members",
|
||||
"contact +list-roles",
|
||||
"contact +list-sub-depts",
|
||||
"contact +lookup",
|
||||
"contact +me",
|
||||
"contact +org",
|
||||
"contact +resolve-dept",
|
||||
"contact +search-mobile",
|
||||
"contact +search-user",
|
||||
"contact +team",
|
||||
"devapp +create",
|
||||
"devapp +delete",
|
||||
"devapp +disable",
|
||||
"devapp +enable",
|
||||
"devapp +event-list",
|
||||
"devapp +get",
|
||||
"devapp +list",
|
||||
"devapp +member-add",
|
||||
"devapp +member-list",
|
||||
"devapp +member-remove",
|
||||
"devapp +permission-list",
|
||||
"devapp +robot-get",
|
||||
"devapp +update",
|
||||
"devapp +version-check-approval",
|
||||
"devapp +version-get",
|
||||
"devapp +version-list",
|
||||
"devapp +version-status",
|
||||
"devapp +webapp-config",
|
||||
"devapp +webapp-get",
|
||||
"ding +list",
|
||||
"ding +recall-personal",
|
||||
"ding +receiver-status",
|
||||
"ding +send-personal",
|
||||
"doc +comment-create",
|
||||
"doc +comment-list",
|
||||
"doc +comment-reply",
|
||||
"doc +copy",
|
||||
"doc +doc-append",
|
||||
"doc +export-get",
|
||||
"doc +export-submit",
|
||||
"doc +find-doc",
|
||||
"doc +list",
|
||||
"doc +move",
|
||||
"doc +search",
|
||||
"doc +share-doc",
|
||||
"doc +template-list",
|
||||
"doc +template-search",
|
||||
"doc +version-list",
|
||||
"doc +version-revert",
|
||||
"doc +version-save",
|
||||
"drive +copy",
|
||||
"drive +find-file",
|
||||
"drive +info",
|
||||
"drive +move",
|
||||
"drive +recent",
|
||||
"drive +search",
|
||||
"drive +search-docs",
|
||||
"mail +contact-list",
|
||||
"mail +find-mail-user",
|
||||
"mail +folder-list",
|
||||
"mail +recent-mail",
|
||||
"mail +search-mail",
|
||||
"mail +tag-list",
|
||||
"mail +template-list",
|
||||
"mail +thread-list",
|
||||
"mail +unread-mail",
|
||||
"mail +user-search",
|
||||
"minutes +detail",
|
||||
"minutes +list-all",
|
||||
"minutes +list-mine",
|
||||
"minutes +list-shared",
|
||||
"minutes +record-start",
|
||||
"minutes +replace-batch",
|
||||
"oa +list-cc",
|
||||
"oa +list-executed",
|
||||
"oa +list-forms",
|
||||
"oa +list-pending",
|
||||
"oa +list-submitted",
|
||||
"oa +my-initiated",
|
||||
"oa +search-forms",
|
||||
"report +inbox-list",
|
||||
"report +outbox-list",
|
||||
"sheet +list-sheets",
|
||||
"sheet +read",
|
||||
"todo +assign",
|
||||
"todo +assign-multi",
|
||||
"todo +created-todos",
|
||||
"todo +get",
|
||||
"todo +get-my-tasks",
|
||||
"todo +list-attachment",
|
||||
"todo +list-comment",
|
||||
"todo +list-sub",
|
||||
"todo +overdue",
|
||||
"todo +remind",
|
||||
"todo +todo-done",
|
||||
"wiki +space-search"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "agoal-out-of-surface",
|
||||
"reason": "The Agoal product remains executable for compatibility but is outside the currently reviewed open-source Agent command surface.",
|
||||
@@ -152,7 +369,6 @@
|
||||
"oa approval revert-activities",
|
||||
"oa approval revert-task",
|
||||
"oa approval search-forms",
|
||||
"todo task list-attachment",
|
||||
"todo task list-sub",
|
||||
"todo task remove-attachment"
|
||||
]
|
||||
|
||||
@@ -465,6 +465,10 @@
|
||||
"canonical_path": "aitable.view_update_visible_fields",
|
||||
"cli_path": "aitable view update visible-fields"
|
||||
},
|
||||
{
|
||||
"canonical_path": "aitable.workflow_create",
|
||||
"cli_path": "aitable workflow create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "aitable.workflow_disable",
|
||||
"cli_path": "aitable workflow disable"
|
||||
@@ -480,6 +484,10 @@
|
||||
{
|
||||
"canonical_path": "aitable.workflow_list",
|
||||
"cli_path": "aitable workflow list"
|
||||
},
|
||||
{
|
||||
"canonical_path": "aitable.workflow_update",
|
||||
"cli_path": "aitable workflow update"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2110,6 +2118,14 @@
|
||||
"canonical_path": "sheet.info",
|
||||
"cli_path": "sheet info"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.import",
|
||||
"cli_path": "sheet import create"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.import_get",
|
||||
"cli_path": "sheet import get"
|
||||
},
|
||||
{
|
||||
"canonical_path": "sheet.insert_dimension",
|
||||
"cli_path": "sheet insert-dimension"
|
||||
@@ -2277,6 +2293,10 @@
|
||||
"canonical_path": "todo.add_todo_attachment",
|
||||
"cli_path": "todo task add-attachment"
|
||||
},
|
||||
{
|
||||
"canonical_path": "todo.list_todo_attachment",
|
||||
"cli_path": "todo task list-attachment"
|
||||
},
|
||||
{
|
||||
"canonical_path": "todo.add_task_executors",
|
||||
"cli_path": "todo task add-executor"
|
||||
|
||||
@@ -89,6 +89,7 @@ func init() {
|
||||
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
|
||||
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
|
||||
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
|
||||
registerRequireOneOf("sheet.import", "folder-token", "workspace")
|
||||
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
|
||||
}
|
||||
|
||||
|
||||
@@ -876,10 +876,6 @@
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
},
|
||||
"todo task list-attachment": {
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
},
|
||||
"todo task list-sub": {
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
|
||||
@@ -959,6 +959,33 @@
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"aitable.workflow_create": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"base-id": {
|
||||
"property": "baseId",
|
||||
"required": true
|
||||
},
|
||||
"dsl": {
|
||||
"property": "dsl",
|
||||
"required": true,
|
||||
"interface_type": "object"
|
||||
},
|
||||
"locale": {
|
||||
"property": "locale"
|
||||
}
|
||||
},
|
||||
"review_reason": "The command maps --base-id and a decoded workflow-dsl/v1 --dsl object to aitable/create_workflow. The call intentionally bypasses retries because creation is non-idempotent; locale remains optional.",
|
||||
"cli_path": "aitable workflow create",
|
||||
"runtime_gate": "none"
|
||||
},
|
||||
"aitable.workflow_disable": {
|
||||
"effect": "write",
|
||||
"risk": "high",
|
||||
@@ -998,6 +1025,37 @@
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"aitable.workflow_update": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "unknown",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true,
|
||||
"parameters": {
|
||||
"base-id": {
|
||||
"property": "baseId",
|
||||
"required": true
|
||||
},
|
||||
"workflow-id": {
|
||||
"property": "workflowId",
|
||||
"required": true
|
||||
},
|
||||
"dsl": {
|
||||
"property": "dsl",
|
||||
"required": true,
|
||||
"interface_type": "object"
|
||||
},
|
||||
"locale": {
|
||||
"property": "locale"
|
||||
}
|
||||
},
|
||||
"review_reason": "The command maps --base-id, --workflow-id and a decoded complete workflow-dsl/v1 --dsl object to aitable/update_workflow. This is full replacement rather than a partial patch; locale remains optional.",
|
||||
"cli_path": "aitable workflow update",
|
||||
"runtime_gate": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +392,28 @@
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"sheet.import": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "non_idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "该 CLI 导入流程包含本地文件校验、创建导入会话、OSS 上传、确认转换和任务轮询,不能绑定为单一 interface_ref。",
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:导入会新建在线电子表格,不修改已有文档;重复执行会创建多个文档,因此标记为 non_idempotent。"
|
||||
},
|
||||
"sheet.import_get": {
|
||||
"effect": "read",
|
||||
"risk": "low",
|
||||
"confirmation": "not_required",
|
||||
"idempotency": "idempotent",
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "该 CLI 续查命令显式复用 doc server 的 query_import_task;当前 Sheet 静态接口快照没有可直接绑定的单一 interface_ref。",
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:仅查询已有导入任务,不创建、覆盖或删除文档。"
|
||||
},
|
||||
"sheet.insert_dimension": {
|
||||
"interface_mode": "mcp",
|
||||
"availability": "available",
|
||||
|
||||
@@ -76,6 +76,12 @@
|
||||
"availability": "available",
|
||||
"reviewed": true
|
||||
},
|
||||
"todo.list_todo_attachment": {
|
||||
"interface_mode": "composite",
|
||||
"availability": "available",
|
||||
"interface_reason": "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command.",
|
||||
"reviewed": true
|
||||
},
|
||||
"todo.remove_task_executors": {
|
||||
"effect": "write",
|
||||
"risk": "medium",
|
||||
|
||||
@@ -825,10 +825,6 @@
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
},
|
||||
"todo task list-attachment": {
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
},
|
||||
"todo task list-sub": {
|
||||
"status": "stale",
|
||||
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"channel": "open-source"
|
||||
},
|
||||
"coverage": {
|
||||
"source_tools": 564,
|
||||
"source_tools": 569,
|
||||
"matched_tools": 71
|
||||
},
|
||||
"tools": {
|
||||
|
||||
@@ -2211,6 +2211,25 @@
|
||||
"dws-schema-live:aitable.update_view"
|
||||
]
|
||||
},
|
||||
"aitable.workflow_create": {
|
||||
"agent_summary": "创建并发布 AI 表格自动化工作流。",
|
||||
"use_when": [
|
||||
"用户明确要求在已知 Base 中创建自动化,且已准备完整 workflow-dsl/v1 定义时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"已有工作流要修改时用 workflow update;仅启停已有工作流用 enable/disable;请求结果不确定时先 list 核对,避免非幂等重复创建"
|
||||
],
|
||||
"examples": [
|
||||
"dws aitable workflow create --base-id <BASE_ID> --dsl @workflow.json --locale zh-CN"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅创建语义,强调完整 DSL、结果 valid/issues 校验及非幂等边界。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#aitable.workflow_create",
|
||||
"cobra-help:dws aitable workflow create --help",
|
||||
"skills/mono/references/products/aitable/aitable-workflow.md"
|
||||
]
|
||||
},
|
||||
"aitable.workflow_disable": {
|
||||
"agent_summary": "禁用工作流(高危,需确认;status 变 STOP)。",
|
||||
"use_when": [
|
||||
@@ -2290,6 +2309,25 @@
|
||||
"skills/mono/references/products/aitable.md",
|
||||
"dws-schema-live:none (helper/composite; Skill+Cobra)"
|
||||
]
|
||||
},
|
||||
"aitable.workflow_update": {
|
||||
"agent_summary": "用完整 DSL 更新并发布已有 AI 表格自动化工作流。",
|
||||
"use_when": [
|
||||
"用户明确要求修改已知工作流,且已先留底当前详情并准备完整 workflow-dsl/v1 目标定义时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"新建工作流用 create;只改变运行状态用 enable/disable;不要把局部 patch 或 workflow get 返回的 flowSchema 直接当作 DSL"
|
||||
],
|
||||
"examples": [
|
||||
"dws aitable workflow update --base-id <BASE_ID> --workflow-id <FLOW_ID> --dsl @workflow.json --locale zh-CN"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据真实 Cobra leaf、Wukong 同步契约和 Aitable workflow Skill 审阅更新语义,强调全量替换、事前备份和 valid/issues 结果校验。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json#aitable.workflow_update",
|
||||
"cobra-help:dws aitable workflow update --help",
|
||||
"skills/mono/references/products/aitable/aitable-workflow.md"
|
||||
]
|
||||
}
|
||||
},
|
||||
"products": {
|
||||
|
||||
@@ -849,6 +849,49 @@
|
||||
"dws-schema-live:sheet.get_sheet"
|
||||
]
|
||||
},
|
||||
"sheet.import": {
|
||||
"agent_summary": "将本地 xlsx/xls 文件导入为新的钉钉在线电子表格。",
|
||||
"use_when": [
|
||||
"用户要把本地 Excel 文件转换为可在线编辑的钉钉表格时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"读取或修改已有在线表格时应使用 sheet list/info/range 等命令",
|
||||
"查询已提交导入任务的状态时使用 sheet import get,不要重复创建导入任务"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet import create --file ./report.xlsx --folder-token <FOLDER_TOKEN> --format json",
|
||||
"dws sheet import create --file ./data.xls --workspace <WORKSPACE_ID> --name \"月度报表\" --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:公开 leaf 与兼容入口 sheet import 复用同一导入流程,使 Agent Schema 能发现并调用真正的导入动作。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json",
|
||||
"cobra-help:dws sheet import create --help",
|
||||
"skills/mono/references/products/sheet/sheet-import.md",
|
||||
"live-mcp:none (Cobra+Skill; composite/unpinned)"
|
||||
]
|
||||
},
|
||||
"sheet.import_get": {
|
||||
"agent_summary": "根据 taskId 查询表格导入任务结果。",
|
||||
"use_when": [
|
||||
"已有 sheet import 返回的 taskId,需要在导入超时或中断后续查转换状态时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"发起新的 xlsx/xls 导入应使用 sheet import;不要用本命令代替导入",
|
||||
"状态仍为 processing 时不要重新提交 import,稍后继续查询同一 taskId"
|
||||
],
|
||||
"examples": [
|
||||
"dws sheet import get --task-id <TASK_ID> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工审阅:该命令与 Wukong sheet import get 契约一致,复用 doc 导入任务查询工具,仅用于已有任务续查。",
|
||||
"source_refs": [
|
||||
"internal/cli/schema_command_registry.json",
|
||||
"cobra-help:dws sheet import get --help",
|
||||
"skills/mono/references/products/sheet/sheet-import.md",
|
||||
"live-mcp:none (Cobra+Skill; composite/unpinned)"
|
||||
]
|
||||
},
|
||||
"sheet.insert_dimension": {
|
||||
"agent_summary": "在指定位置之前插入空行或空列。",
|
||||
"use_when": [
|
||||
@@ -1552,12 +1595,14 @@
|
||||
},
|
||||
"products": {
|
||||
"sheet": {
|
||||
"agent_summary": "创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
|
||||
"agent_summary": "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。",
|
||||
"use_when": [
|
||||
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时"
|
||||
"用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时",
|
||||
"用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"目标是 AI 表格 Base 的结构化记录、钉钉文档正文或本地 Excel 文件时不要使用 sheet"
|
||||
"目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet",
|
||||
"只读取已上传但未转换的 xlsx/xls 节点时先用 doc download;不要把文件节点直接传给单元格命令"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "人工结合实时 MCP/Skill/Cobra 审阅产品级选择边界。",
|
||||
|
||||
@@ -286,6 +286,29 @@
|
||||
"live-dws-schema:todo.list_todo_comment"
|
||||
]
|
||||
},
|
||||
"todo.list_todo_attachment": {
|
||||
"agent_summary": "查询待办附件列表",
|
||||
"use_when": [
|
||||
"已知 taskId,需要查看该待办当前附件及其 attachmentId、文件名和大小时"
|
||||
],
|
||||
"avoid_when": [
|
||||
"要上传附件时改用 dws todo task add-attachment",
|
||||
"要删除附件时先用本命令确认 attachmentId,再改用 dws todo task remove-attachment"
|
||||
],
|
||||
"examples": [
|
||||
"dws todo task list-attachment --task-id <taskId>",
|
||||
"dws todo task list-attachment --task-id <taskId> --format json"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "依据当前 Cobra Help、Todo Skill 与 helper 中的 list_todo_attachment 调用手工审阅;当前 live leaf Schema 缺失,记录失败证据并以源码契约收口。",
|
||||
"source_refs": [
|
||||
"CommandRegistry:canonical_path=todo.list_todo_attachment",
|
||||
"cobra-help:dws todo task list-attachment",
|
||||
"Skill:skills/mono/references/products/todo.md",
|
||||
"runtime-call:internal/helpers/todo.go#list_todo_attachment",
|
||||
"live-dws-schema:todo.list_todo_attachment#FAILED"
|
||||
]
|
||||
},
|
||||
"todo.remove_task_executors": {
|
||||
"agent_summary": "移除待办执行人",
|
||||
"use_when": [
|
||||
|
||||
@@ -1874,6 +1874,7 @@
|
||||
"sheet.write_image --mime-type": "local upload metadata",
|
||||
"sheet.write_image --name": "local upload metadata",
|
||||
"todo.add_todo_attachment --file-path": "local upload input used to construct attachmentList",
|
||||
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
|
||||
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
|
||||
"wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document",
|
||||
"wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces",
|
||||
|
||||
@@ -199,17 +199,13 @@ func TestReadFileBoundedEmptyFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileBoundedPermissionDenied(t *testing.T) {
|
||||
func TestReadFileBoundedRejectsDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "noperm.txt")
|
||||
writeTestFile(t, path, []byte("secret"))
|
||||
os.Chmod(path, 0o000)
|
||||
t.Cleanup(func() { os.Chmod(path, 0o644) })
|
||||
|
||||
_, _, err := ReadFileArg("@" + path)
|
||||
_, _, err := ReadFileArg("@" + dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for permission denied")
|
||||
t.Fatal("expected error when reading a directory as a file")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,17 +127,6 @@ func TestPersonalSourceFetchTicketConnectsAndACKs(t *testing.T) {
|
||||
if ev.EventType != "user_im_message_receive_at" || ev.EventID != "evt-1" {
|
||||
t.Fatalf("event = %#v", ev)
|
||||
}
|
||||
out := logs.String()
|
||||
for _, want := range []string{"personal source received dataframe", "user_im_message_receive_at", "evt-1", "sub-1", "sourceId", "message", "<redacted>"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("debug log missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
for _, leaked := range []string{"header-secret-token", "data-secret-token", "data-secret", "data-ticket", "Bearer data-auth"} {
|
||||
if strings.Contains(out, leaked) {
|
||||
t.Fatalf("debug log leaked %q: %s", leaked, out)
|
||||
}
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for event")
|
||||
}
|
||||
@@ -158,6 +147,21 @@ func TestPersonalSourceFetchTicketConnectsAndACKs(t *testing.T) {
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("source did not stop after cancel")
|
||||
}
|
||||
|
||||
// Start can log a reconnect after emitting the event and before observing
|
||||
// cancellation. Read the shared log buffer only after the goroutine exits so
|
||||
// the assertion remains race-free under `go test -race`.
|
||||
out := logs.String()
|
||||
for _, want := range []string{"personal source received dataframe", "user_im_message_receive_at", "evt-1", "sub-1", "sourceId", "message", "<redacted>"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("debug log missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
for _, leaked := range []string{"header-secret-token", "data-secret-token", "data-secret", "data-ticket", "Bearer data-auth"} {
|
||||
if strings.Contains(out, leaked) {
|
||||
t.Fatalf("debug log leaked %q: %s", leaked, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalSourceReconnectsWithFreshTicket(t *testing.T) {
|
||||
|
||||
@@ -101,7 +101,7 @@ func TestGenerateRejectsIncompleteSelectionCoverage(t *testing.T) {
|
||||
|
||||
func TestGenerateRequiresAgentHintDirectory(t *testing.T) {
|
||||
_, _, err := Generate(Options{})
|
||||
if err == nil || !strings.Contains(err.Error(), "Agent hint directory is required") {
|
||||
if err == nil || !strings.Contains(err.Error(), "agent hint directory is required") {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +369,7 @@ var (
|
||||
|
||||
func Generate(opts Options) (File, Stats, error) {
|
||||
if strings.TrimSpace(opts.HintsDir) == "" {
|
||||
return File{}, Stats{}, fmt.Errorf("Agent hint directory is required")
|
||||
return File{}, Stats{}, fmt.Errorf("agent hint directory is required")
|
||||
}
|
||||
if len(opts.CanonicalToolPaths) == 0 || len(opts.ToolPaths) == 0 || len(opts.ProductIDs) == 0 {
|
||||
return File{}, Stats{}, fmt.Errorf("complete Effective CommandRegistry projection is required")
|
||||
|
||||
+124
-3
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -81,6 +82,44 @@ func resolveRecordsFlag(cmd *cobra.Command) (string, error) {
|
||||
return "", fmt.Errorf("missing required flag(s): --records (example: --records '[{\"cells\":{\"fldTextId\":\"文本内容\"}}]')\n hint: for large payloads or Windows, use --records-file ./path/to/records.json")
|
||||
}
|
||||
|
||||
// resolveWorkflowDSL reads --dsl from inline JSON, @file, or stdin (-), then
|
||||
// decodes the MCP-facing workflow-dsl/v1 object. Detailed DSL validation stays
|
||||
// on the workflow service so callers receive its structured issues response.
|
||||
func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
|
||||
raw := mustGetFlag(cmd, "dsl")
|
||||
switch {
|
||||
case raw == "-":
|
||||
data, err := io.ReadAll(cmd.InOrStdin())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("--dsl stdin read failed: %w", err)
|
||||
}
|
||||
raw = string(data)
|
||||
case strings.HasPrefix(raw, "@"):
|
||||
path := strings.TrimSpace(strings.TrimPrefix(raw, "@"))
|
||||
if path == "" {
|
||||
return nil, fmt.Errorf("--dsl file path must not be empty\n hint: use --dsl @workflow.json")
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("--dsl file read failed: %w\n hint: ensure the file exists and contains a workflow-dsl/v1 JSON object", err)
|
||||
}
|
||||
raw = string(data)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, fmt.Errorf("--dsl must not be empty\n hint: pass inline JSON, @workflow.json, or - for stdin")
|
||||
}
|
||||
|
||||
var dsl map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &dsl); err != nil {
|
||||
return nil, fmt.Errorf("--dsl JSON parse failed: %w\n hint: --dsl must be a workflow-dsl/v1 JSON object", err)
|
||||
}
|
||||
if dsl == nil {
|
||||
return nil, fmt.Errorf("--dsl must be a JSON object, got null")
|
||||
}
|
||||
return dsl, nil
|
||||
}
|
||||
|
||||
// recordQueryFetchAll implements --all auto-pagination for record query.
|
||||
// - pageLimit controls max pages (default 50, 0 = unlimited)
|
||||
// - Mid-loop errors break gracefully, outputting already-fetched data
|
||||
@@ -3204,16 +3243,87 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
|
||||
workflowCmd := &cobra.Command{
|
||||
Use: "workflow",
|
||||
Short: "自动化工作流管理(启停 / 查看 / 列表)",
|
||||
Short: "自动化工作流管理(创建 / 更新 / 启停 / 查看 / 列表)",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
workflowCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建并发布自动化工作流",
|
||||
Long: `在指定 Base 中创建并发布自动化工作流。
|
||||
--dsl 必须是完整的 workflow-dsl/v1 JSON 对象;涉及数据表、字段或视图的节点应使用真实的 sheetId / fieldId / viewId。
|
||||
|
||||
--dsl 支持内联 JSON、@文件路径,或 - 从 stdin 读取。创建属于非幂等操作,CLI 不会自动重试。
|
||||
返回 data.valid、flowId、flowSchema、stepNodeIds、referenceMap、issues;即使 status=success,
|
||||
valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正后再调用。`,
|
||||
Example: ` dws aitable workflow create --base-id BASE_ID --dsl @workflow.json --locale zh-CN
|
||||
cat workflow.json | dws aitable workflow create --base-id BASE_ID --dsl -`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "dsl"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dsl, err := resolveWorkflowDSL(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"dsl": dsl,
|
||||
}
|
||||
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
|
||||
toolArgs["locale"] = locale
|
||||
}
|
||||
// create_workflow is non-idempotent. Bypass the retry wrapper to
|
||||
// prevent an uncertain first response from creating a duplicate.
|
||||
return callMCPToolOnServer("aitable", "create_workflow", toolArgs)
|
||||
},
|
||||
}
|
||||
|
||||
workflowUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新并发布已有自动化工作流",
|
||||
Long: `在指定 Base 中更新并发布已有自动化工作流。
|
||||
建议先用 workflow get 留底当前详情;--dsl 必须是完整的 workflow-dsl/v1 JSON 对象,而不是局部 patch。
|
||||
|
||||
--dsl 支持内联 JSON、@文件路径,或 - 从 stdin 读取。更新会发布传入的目标 DSL;请提供完整、可独立校验的 workflow-dsl/v1 对象。
|
||||
返回 data.valid、flowId、flowSchema、stepNodeIds、referenceMap、issues;即使 status=success,
|
||||
valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正后再调用。`,
|
||||
Example: ` dws aitable workflow update --base-id BASE_ID --workflow-id WORKFLOW_ID --dsl @workflow.json --locale zh-CN
|
||||
cat workflow.json | dws aitable workflow update --base-id BASE_ID --workflow-id WORKFLOW_ID --dsl -`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "workflow-id", "dsl"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dsl, err := resolveWorkflowDSL(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"workflowId": mustGetFlag(cmd, "workflow-id"),
|
||||
"dsl": dsl,
|
||||
}
|
||||
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
|
||||
toolArgs["locale"] = locale
|
||||
}
|
||||
return callAitableTool("update_workflow", toolArgs)
|
||||
},
|
||||
}
|
||||
|
||||
workflowEnableCmd := &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "启用指定工作流",
|
||||
Long: `启用指定 Base 中的自动化工作流。启用后工作流将按配置的触发条件自动执行。
|
||||
返回 {workflowId, enabled} 用于确认操作结果(enabled 为动作确认而非状态查询)。
|
||||
当前不支持通过 CLI 新建工作流,请在 AI 表格 Web 端配置好后用 workflow list 拿到 workflowId 再启停。`,
|
||||
可用 workflow create 创建工作流,或用 workflow list 获取已有 workflowId。`,
|
||||
Example: ` dws aitable workflow enable --base-id BASE_ID --workflow-id WORKFLOW_ID
|
||||
# 查询 workflowId: dws aitable workflow list --base-id <baseId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -4750,6 +4860,13 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
formCmd.AddCommand(formListCmd, formGetCmd, formCreateCmd, formDeleteCmd, formUpdateCmd, formFieldCmd, formShareCmd, formQuestionsCmd)
|
||||
|
||||
// workflow
|
||||
workflowCreateCmd.Flags().String("base-id", "", "目标 Base ID (必填)")
|
||||
workflowCreateCmd.Flags().String("dsl", "", "workflow-dsl/v1 JSON 对象;支持内联 JSON、@文件路径或 - 从 stdin 读取 (必填)")
|
||||
workflowCreateCmd.Flags().String("locale", "", "请求语言,例如 zh-CN 或 zh_CN (可选)")
|
||||
workflowUpdateCmd.Flags().String("base-id", "", "目标 Base ID (必填)")
|
||||
workflowUpdateCmd.Flags().String("workflow-id", "", "目标工作流 ID (必填)")
|
||||
workflowUpdateCmd.Flags().String("dsl", "", "workflow-dsl/v1 JSON 对象;支持内联 JSON、@文件路径或 - 从 stdin 读取 (必填)")
|
||||
workflowUpdateCmd.Flags().String("locale", "", "请求语言,例如 zh-CN 或 zh_CN (可选)")
|
||||
workflowEnableCmd.Flags().String("base-id", "", "目标 Base ID (必填)")
|
||||
workflowEnableCmd.Flags().String("workflow-id", "", "目标工作流 ID (必填)")
|
||||
workflowDisableCmd.Flags().String("base-id", "", "目标 Base ID (必填)")
|
||||
@@ -4759,7 +4876,11 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
workflowListCmd.Flags().String("base-id", "", "目标 Base ID (必填)")
|
||||
workflowListCmd.Flags().Int("limit", 0, "分页大小 [1, 100],不传走服务端默认 20")
|
||||
workflowListCmd.Flags().Int("offset", 0, "分页偏移量,>= 0,不传走服务端默认 0")
|
||||
workflowCmd.AddCommand(workflowEnableCmd, workflowDisableCmd, workflowGetCmd, workflowListCmd)
|
||||
workflowCmd.AddCommand(
|
||||
workflowCreateCmd, workflowUpdateCmd,
|
||||
workflowEnableCmd, workflowDisableCmd,
|
||||
workflowGetCmd, workflowListCmd,
|
||||
)
|
||||
|
||||
// dashboard
|
||||
dashboardGetCmd.Flags().String("base-id", "", "所属 Base ID (必填)")
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type aitableWorkflowCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type aitableWorkflowCaller struct {
|
||||
calls []aitableWorkflowCall
|
||||
}
|
||||
|
||||
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (*aitableWorkflowCaller) Format() string { return "json" }
|
||||
func (*aitableWorkflowCaller) DryRun() bool { return false }
|
||||
func (*aitableWorkflowCaller) Fields() string { return "" }
|
||||
func (*aitableWorkflowCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
caller := &aitableWorkflowCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
|
||||
|
||||
cmd := newAitableCommand()
|
||||
cmd.PersistentFlags().String("format", "json", "output format")
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append([]string{"workflow"}, args...))
|
||||
if stdin != nil {
|
||||
cmd.SetIn(stdin)
|
||||
}
|
||||
return caller, cmd.Execute()
|
||||
}
|
||||
|
||||
func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
wantDSL := map[string]any{
|
||||
"version": "workflow-dsl/v1",
|
||||
"name": "create test",
|
||||
}
|
||||
caller, err := runAitableWorkflowCommand(t, nil,
|
||||
"create",
|
||||
"--base-id", "base-create",
|
||||
"--dsl", `{"version":"workflow-dsl/v1","name":"create test"}`,
|
||||
"--locale", "zh-CN",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("workflow create returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "aitable" || call.toolName != "create_workflow" {
|
||||
t.Fatalf("tool call = %s/%s, want aitable/create_workflow", call.productID, call.toolName)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"baseId": "base-create",
|
||||
"dsl": wantDSL,
|
||||
"locale": "zh-CN",
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableWorkflowUpdateReadsDSLFile(t *testing.T) {
|
||||
path := t.TempDir() + "/workflow.json"
|
||||
if err := os.WriteFile(path, []byte(`{"version":"workflow-dsl/v1","name":"updated"}`), 0o600); err != nil {
|
||||
t.Fatalf("write workflow fixture: %v", err)
|
||||
}
|
||||
caller, err := runAitableWorkflowCommand(t, nil,
|
||||
"update",
|
||||
"--base-id", "base-update",
|
||||
"--workflow-id", "flow-existing",
|
||||
"--dsl", "@"+path,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("workflow update returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "aitable" || call.toolName != "update_workflow" {
|
||||
t.Fatalf("tool call = %s/%s, want aitable/update_workflow", call.productID, call.toolName)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"baseId": "base-update",
|
||||
"workflowId": "flow-existing",
|
||||
"dsl": map[string]any{
|
||||
"version": "workflow-dsl/v1",
|
||||
"name": "updated",
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableWorkflowCreateReadsDSLFromStdin(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t,
|
||||
strings.NewReader(`{"version":"workflow-dsl/v1","name":"stdin"}`),
|
||||
"create", "--base-id", "base-stdin", "--dsl", "-",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("workflow create from stdin returned error: %v", err)
|
||||
}
|
||||
if got := caller.calls[0].args["dsl"].(map[string]any)["name"]; got != "stdin" {
|
||||
t.Fatalf("dsl name = %#v, want stdin", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableWorkflowWriteRejectsInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "create missing dsl", args: []string{"create", "--base-id", "base"}, want: "dsl"},
|
||||
{name: "update missing workflow", args: []string{"update", "--base-id", "base", "--dsl", `{}`}, want: "workflow-id"},
|
||||
{name: "malformed json", args: []string{"create", "--base-id", "base", "--dsl", "{not-json"}, want: "JSON parse failed"},
|
||||
{name: "array", args: []string{"create", "--base-id", "base", "--dsl", `[]`}, want: "JSON parse failed"},
|
||||
{name: "null", args: []string{"create", "--base-id", "base", "--dsl", `null`}, want: "JSON object"},
|
||||
{name: "empty file path", args: []string{"create", "--base-id", "base", "--dsl", "@"}, want: "file path"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tc.want)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid input reached MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -27,13 +28,15 @@ func TestAtomicWrite_Basic(t *testing.T) {
|
||||
t.Fatalf("got %q, want %q", got, data)
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Fatalf("permissions = %o, want 0600", perm)
|
||||
// Windows does not expose POSIX permission bits.
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Fatalf("permissions = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,12 +152,14 @@ func TestAtomicWriteJSON_Basic(t *testing.T) {
|
||||
t.Fatalf("got %q, want %q", got, data)
|
||||
}
|
||||
|
||||
// Check permissions are 0600
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Fatalf("permissions = %o, want 0600", perm)
|
||||
// Windows does not expose POSIX permission bits.
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat() error = %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0600 {
|
||||
t.Fatalf("permissions = %o, want 0600", perm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+22
-22
@@ -1367,9 +1367,10 @@ func newChatCommand() *cobra.Command {
|
||||
timeVal := mustGetFlag(cmd, "time")
|
||||
if groupID != "" {
|
||||
toolArgs := map[string]any{
|
||||
"openconversation_id": groupID,
|
||||
"time": timeVal,
|
||||
"forward": forward,
|
||||
"openCid": groupID,
|
||||
"cid": groupID,
|
||||
"time": timeVal,
|
||||
"forward": forward,
|
||||
}
|
||||
if v := chatIntFlagOrFallback(cmd, "limit", "size"); v > 0 {
|
||||
toolArgs["limit"] = v
|
||||
@@ -1495,14 +1496,13 @@ func newChatCommand() *cobra.Command {
|
||||
openDingTalkID = userID
|
||||
userID = ""
|
||||
}
|
||||
// 数字 userId 尝试 lookup 转换为 openDingTalkId,让所有消息类型(文本/媒体)都走 openDingTalkId 路径
|
||||
// lookup 失败时降级保留 userID,由下游用 receiverUserId 发送
|
||||
// 数字 userId 尝试 lookup 转换为 openDingTalkId,让所有消息类型(文本/媒体)都走 openDingTalkId 路径。
|
||||
// 真实后端的 send_personal_message 单聊路径稳定接受 receiverOpenDingTalkId;
|
||||
// userId/uid 直传会被服务端判定为空,因此解析失败时直接返回明确错误。
|
||||
if userID != "" {
|
||||
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
|
||||
if err != nil {
|
||||
if commandBoolFlag(cmd, "debug") || commandBoolFlag(cmd, "verbose") {
|
||||
fmt.Fprintf(os.Stderr, "[debug] resolveOpenDingTalkID(%q) failed: %v, falling back to receiverUserId\n", userID, err)
|
||||
}
|
||||
return fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
|
||||
} else {
|
||||
if commandBoolFlag(cmd, "debug") || commandBoolFlag(cmd, "verbose") {
|
||||
fmt.Fprintf(os.Stderr, "[debug] resolved userID=%q to openDingTalkId=%q\n", userID, resolved)
|
||||
@@ -1606,7 +1606,7 @@ func newChatCommand() *cobra.Command {
|
||||
} else if openDingTalkID != "" {
|
||||
params["receiverOpenDingTalkId"] = openDingTalkID
|
||||
} else if userID != "" {
|
||||
params["receiverUserId"] = userID
|
||||
params["receiverUid"] = userID
|
||||
} else {
|
||||
return fmt.Errorf("--group, --user or --open-dingtalk-id is required for media messages")
|
||||
}
|
||||
@@ -1662,10 +1662,10 @@ func newChatCommand() *cobra.Command {
|
||||
if userID != "" {
|
||||
directContentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||
directMsgParams := map[string]any{
|
||||
"receiverUserId": userID,
|
||||
"msgType": "markdown",
|
||||
"content": string(directContentJSON),
|
||||
"clawType": clawType,
|
||||
"receiverUid": userID,
|
||||
"msgType": "markdown",
|
||||
"content": string(directContentJSON),
|
||||
"clawType": clawType,
|
||||
}
|
||||
if msgUuid != "" {
|
||||
directMsgParams["uuid"] = msgUuid
|
||||
@@ -4431,8 +4431,8 @@ status 可选值:
|
||||
AuditIgnore — 忽略(服务端拒绝,不可用)
|
||||
AuditRefuse — 拒绝(服务端拒绝,不可用)
|
||||
AuditBlock — 拒绝且不再接受该用户的申请(服务端拒绝,不可用)`,
|
||||
Example: ` dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditApprove
|
||||
dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditDelete --description "不符合入群条件"
|
||||
Example: ` dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <userId> --inviter <userId> --status AuditApprove
|
||||
dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <userId> --inviter <userId> --status AuditDelete --description "不符合入群条件"
|
||||
# 查询入群验证记录: dws chat group list-join-validations`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group", "record-id", "applicant", "inviter", "status"); err != nil {
|
||||
@@ -4443,11 +4443,11 @@ status 可选值:
|
||||
return fmt.Errorf("--record-id must be a valid integer: %w", err)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"applyRecordId": recordID,
|
||||
"applicantOpenDingTalkId": mustGetFlag(cmd, "applicant"),
|
||||
"inviterOpenDingTalkId": mustGetFlag(cmd, "inviter"),
|
||||
"status": mustGetFlag(cmd, "status"),
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"applyRecordId": recordID,
|
||||
"applicantUid": mustGetFlag(cmd, "applicant"),
|
||||
"inviterUid": mustGetFlag(cmd, "inviter"),
|
||||
"status": mustGetFlag(cmd, "status"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("description"); v != "" {
|
||||
toolArgs["auditDescription"] = v
|
||||
@@ -4461,9 +4461,9 @@ status 可选值:
|
||||
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("record-id")
|
||||
chatGroupAuditJoinValidationCmd.Flags().String("status", "", "审批动作,真机仅 AuditApprove/AuditDelete 可用;AuditIgnore/AuditRefuse/AuditBlock 服务端拒绝 (必填)")
|
||||
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("status")
|
||||
chatGroupAuditJoinValidationCmd.Flags().String("applicant", "", "申请人 openDingTalkId (必填)")
|
||||
chatGroupAuditJoinValidationCmd.Flags().String("applicant", "", "申请人 userId (必填)")
|
||||
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("applicant")
|
||||
chatGroupAuditJoinValidationCmd.Flags().String("inviter", "", "邀请人 openDingTalkId (必填)")
|
||||
chatGroupAuditJoinValidationCmd.Flags().String("inviter", "", "邀请人 userId (必填)")
|
||||
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("inviter")
|
||||
chatGroupAuditJoinValidationCmd.Flags().String("description", "", "审批说明(可选)")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -44,7 +45,7 @@ func (*chatMessageSearchCaller) DryRun() bool { return false }
|
||||
func (*chatMessageSearchCaller) Fields() string { return "" }
|
||||
func (*chatMessageSearchCaller) JQ() string { return "" }
|
||||
|
||||
func TestChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
@@ -127,3 +128,101 @@ func TestChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type chatChangedContractCaller struct {
|
||||
calls []chatMessageSearchCall
|
||||
resolveUsers bool
|
||||
}
|
||||
|
||||
func (c *chatChangedContractCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args})
|
||||
text := `{}`
|
||||
if c.resolveUsers && toolName == "get_user_info_by_user_ids" {
|
||||
text = `{"result":[{"userId":"123","openDingTalkId":"open-123"}]}`
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (*chatChangedContractCaller) Format() string { return "json" }
|
||||
func (*chatChangedContractCaller) DryRun() bool { return false }
|
||||
func (*chatChangedContractCaller) Fields() string { return "" }
|
||||
func (*chatChangedContractCaller) JQ() string { return "" }
|
||||
|
||||
func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageListUsesObservedGroupKeys(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller,
|
||||
"message", "list", "--group", "cid-1", "--time", "2026-07-15 09:00:00", "--limit", "50")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "list_conversation_message_v2" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
want := map[string]any{"openCid": "cid-1", "cid": "cid-1", "time": "2026-07-15 09:00:00", "forward": true, "limit": 50}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatAuditUsesUserIDs(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller,
|
||||
"group", "audit-join-validation",
|
||||
"--group", "cid-1", "--record-id", "123", "--applicant", "user-a", "--inviter", "user-b", "--status", "AuditApprove")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "audit_join_group" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
want := map[string]any{
|
||||
"openConversationId": "cid-1", "applyRecordId": int64(123),
|
||||
"applicantUid": "user-a", "inviterUid": "user-b", "status": "AuditApprove",
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("tool args = %#v, want %#v", caller.calls[0].args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendResolvesUserBeforeDispatch(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{resolveUsers: true}
|
||||
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[1].toolName != "send_personal_message" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if got := caller.calls[1].args["receiverOpenDingTalkId"]; got != "open-123" {
|
||||
t.Fatalf("receiverOpenDingTalkId = %#v, args = %#v", got, caller.calls[1].args)
|
||||
}
|
||||
if _, leaked := caller.calls[1].args["receiverUid"]; leaked {
|
||||
t.Fatalf("resolved send must not include receiverUid: %#v", caller.calls[1].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
|
||||
if err == nil || !strings.Contains(err.Error(), "pass --open-dingtalk-id instead") {
|
||||
t.Fatalf("error = %v, want explicit resolution failure", err)
|
||||
}
|
||||
for _, call := range caller.calls {
|
||||
if call.toolName == "send_personal_message" {
|
||||
t.Fatalf("unresolved user must not be dispatched: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -24,7 +25,20 @@ import (
|
||||
// writeExecStub drops an executable shell stub named name into dir so PATH
|
||||
// lookups resolve without the real CLI installed.
|
||||
func writeExecStub(dir, name string) error {
|
||||
return os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"), 0o755)
|
||||
path := filepath.Join(dir, name)
|
||||
body := []byte("#!/bin/sh\n")
|
||||
if runtime.GOOS == "windows" {
|
||||
path += ".exe"
|
||||
body = nil
|
||||
}
|
||||
return os.WriteFile(path, body, 0o755)
|
||||
}
|
||||
|
||||
func requirePOSIXShell(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("test fixture requires a POSIX shell executable")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConvSessions covers the per-conversation session contract: first message
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
|
||||
func writeShellExecutable(t *testing.T, dir, name, body string) string {
|
||||
t.Helper()
|
||||
requirePOSIXShell(t)
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body), 0o755); err != nil {
|
||||
t.Fatalf("write stub %s: %v", name, err)
|
||||
@@ -37,7 +38,9 @@ func TestForwarderForChannelCodexPrefersAppServer(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
stub := t.TempDir()
|
||||
writeShellExecutable(t, stub, "codex", "exit 0\n")
|
||||
if err := writeExecStub(stub, "codex"); err != nil {
|
||||
t.Fatalf("write codex stub: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", stub)
|
||||
|
||||
fwd, err := forwarderForChannel("codex", "", connectAgentOptions{Memory: true})
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -89,12 +90,14 @@ func TestStageDaemonExecutable(t *testing.T) {
|
||||
if string(got) != "test-binary" {
|
||||
t.Fatalf("content = %q, want test-binary", got)
|
||||
}
|
||||
info, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != daemonExecutablePerm {
|
||||
t.Fatalf("mode = %o, want %o", info.Mode().Perm(), daemonExecutablePerm)
|
||||
if runtime.GOOS != "windows" {
|
||||
info, err := os.Stat(dst)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != daemonExecutablePerm {
|
||||
t.Fatalf("mode = %o, want %o", info.Mode().Perm(), daemonExecutablePerm)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
)
|
||||
|
||||
func TestExecForwarderRetriesMissingSessionOnce(t *testing.T) {
|
||||
requirePOSIXShell(t)
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "agent")
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
@@ -68,6 +69,7 @@ echo fresh-ok
|
||||
}
|
||||
|
||||
func TestExecForwarderStreamRetriesMissingSessionOnce(t *testing.T) {
|
||||
requirePOSIXShell(t)
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "agent-stream")
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
@@ -113,6 +115,7 @@ printf '%s\n' '{"type":"result","result":"fresh-stream-ok"}'
|
||||
}
|
||||
|
||||
func TestExecForwarderDoesNotRetryNonSessionErrors(t *testing.T) {
|
||||
requirePOSIXShell(t)
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "agent")
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
eventprocess "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
|
||||
)
|
||||
|
||||
// connectLockDir is a var so tests can isolate lock files.
|
||||
@@ -67,16 +67,8 @@ func sanitizeLockID(s string) string {
|
||||
}, s)
|
||||
}
|
||||
|
||||
// processAlive reports whether pid refers to a live process (unix signal-0
|
||||
// probe; a permission error still means "alive").
|
||||
// processAlive reports whether pid refers to a live process using the shared
|
||||
// Unix signal-0 / Windows OpenProcess implementation.
|
||||
func processAlive(pid int) bool {
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
err = proc.Signal(syscall.Signal(0))
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, syscall.EPERM)
|
||||
return eventprocess.Alive(pid)
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func TestForwarderProviderBaseURLDropsHaiku(t *testing.T) {
|
||||
// agent that prints "API Error: 422 ..." to stdout (and exits 0) must NOT have
|
||||
// that raw error forwarded as the answer; forward returns an actionable hint.
|
||||
func TestForwardReturnsHintOnAPIError(t *testing.T) {
|
||||
requirePOSIXShell(t)
|
||||
stub := t.TempDir()
|
||||
bin := filepath.Join(stub, "fakeagent")
|
||||
// Print a provider 422 to stdout and exit 0, exactly like claude does when
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
|
||||
func writeQoderStreamStub(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
requirePOSIXShell(t)
|
||||
logPath := filepath.Join(dir, "qoder-starts.log")
|
||||
script := `#!/usr/bin/env python3
|
||||
import json
|
||||
|
||||
@@ -40,6 +40,7 @@ func TestForwardConnectTurnPreservesAttachmentsForCapableAgent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecForwarderAllowsOnlyAttachmentDirectory(t *testing.T) {
|
||||
requirePOSIXShell(t)
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "args.log")
|
||||
stub := filepath.Join(dir, "agent")
|
||||
|
||||
@@ -223,7 +223,9 @@ func TestResolveAgentYoloMode(t *testing.T) {
|
||||
func TestForwarderForChannel(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
stub := t.TempDir()
|
||||
writeShellExecutable(t, stub, "codex", "exit 0\n")
|
||||
if err := writeExecStub(stub, "codex"); err != nil {
|
||||
t.Fatalf("write codex stub: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", stub)
|
||||
// DWS_AGENT_CMD covers ordinary stream-bridge channels so the test does not
|
||||
// depend on those binaries being installed. Codex ignores it and stays on
|
||||
|
||||
+2
-221
@@ -2543,133 +2543,7 @@ CLI 内部自动完成全部流程:
|
||||
# 自定义导入后的文档名称
|
||||
dws doc import --file ./draft.md --name "项目周报"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filePath := mustGetFlag(cmd, "file")
|
||||
if filePath == "" && len(args) > 0 {
|
||||
filePath = args[0]
|
||||
}
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("flag --file is required (or pass file path as argument)")
|
||||
}
|
||||
|
||||
fi, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return fmt.Errorf("%s is a directory, not a file", filePath)
|
||||
}
|
||||
|
||||
const maxFileSize = 20 * 1024 * 1024
|
||||
fileSize := fi.Size()
|
||||
if fileSize > maxFileSize {
|
||||
return fmt.Errorf("file size %d bytes exceeds 20MB limit", fileSize)
|
||||
}
|
||||
if fileSize == 0 {
|
||||
return fmt.Errorf("file is empty: %s", filePath)
|
||||
}
|
||||
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
supportedFormats := map[string]bool{
|
||||
"docx": true, "doc": true, "xlsx": true, "xls": true,
|
||||
"md": true, "txt": true, "xmind": true, "mark": true,
|
||||
}
|
||||
if !supportedFormats[ext] {
|
||||
return fmt.Errorf("unsupported file format %q, supported: docx, doc, xlsx, xls, md, txt, xmind, mark", ext)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
||||
}
|
||||
folder := flagOrFallback(cmd, "folder", "folder-id")
|
||||
workspace := flagOrFallback(cmd, "workspace", "workspace-id")
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "导入本地文件为在线文档")
|
||||
deps.Out.PrintKeyValue("文件", filePath)
|
||||
deps.Out.PrintKeyValue("名称", name)
|
||||
deps.Out.PrintKeyValue("格式", ext)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
deps.Out.PrintInfo("[1/4] 创建导入会话...")
|
||||
sessionArgs := map[string]any{
|
||||
"fileName": name,
|
||||
"suffix": ext,
|
||||
"fileSize": fileSize,
|
||||
}
|
||||
if folder != "" {
|
||||
sessionArgs["targetFolderId"] = folder
|
||||
}
|
||||
if workspace != "" {
|
||||
sessionArgs["workspaceId"] = workspace
|
||||
}
|
||||
|
||||
sessionText, err := callMCPToolReturnText(ctx, "create_import_session", sessionArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建导入会话失败: %w", err)
|
||||
}
|
||||
|
||||
var sessionResult map[string]any
|
||||
if err := json.Unmarshal([]byte(sessionText), &sessionResult); err != nil {
|
||||
return fmt.Errorf("解析导入会话响应失败: %w", err)
|
||||
}
|
||||
sessionID, _ := sessionResult["sessionId"].(string)
|
||||
uploadURL, _ := sessionResult["uploadUrl"].(string)
|
||||
if sessionID == "" || uploadURL == "" {
|
||||
deps.Out.PrintRaw(sessionText)
|
||||
return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl")
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID))
|
||||
|
||||
deps.Out.PrintInfo("[2/4] 上传文件...")
|
||||
if err := httpPutFile(ctx, uploadURL, nil, filePath, fileSize); err != nil {
|
||||
return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err)
|
||||
}
|
||||
deps.Out.PrintInfo(" 文件上传完成")
|
||||
|
||||
deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...")
|
||||
confirmText, err := callMCPToolReturnText(ctx, "confirm_import", map[string]any{
|
||||
"sessionId": sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err)
|
||||
}
|
||||
|
||||
var confirmResult map[string]any
|
||||
if err := json.Unmarshal([]byte(confirmText), &confirmResult); err != nil {
|
||||
return fmt.Errorf("解析确认导入响应失败: %w", err)
|
||||
}
|
||||
taskID, _ := confirmResult["taskId"].(string)
|
||||
if taskID == "" {
|
||||
deps.Out.PrintRaw(confirmText)
|
||||
return fmt.Errorf("确认导入成功但未返回 taskId")
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID))
|
||||
|
||||
deps.Out.PrintInfo("[4/4] 等待格式转换完成...")
|
||||
importResult, err := pollDocImportTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
documentURL, _ := importResult["documentUrl"].(string)
|
||||
documentName, _ := importResult["documentName"].(string)
|
||||
documentType, _ := importResult["documentType"].(string)
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
|
||||
deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"taskId": taskID,
|
||||
"documentUrl": documentURL,
|
||||
"documentName": documentName,
|
||||
"documentType": documentType,
|
||||
})
|
||||
return nil
|
||||
return runImportCommand(cmd, args, docImportFlowConfig())
|
||||
},
|
||||
}
|
||||
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||||
@@ -2694,43 +2568,7 @@ CLI 内部自动完成全部流程:
|
||||
failed 导入失败`,
|
||||
Example: ` dws doc import get --task-id <TASK_ID>`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
taskID := mustGetFlag(cmd, "task-id")
|
||||
if taskID == "" {
|
||||
return fmt.Errorf("flag --task-id is required")
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "查询导入任务结果")
|
||||
deps.Out.PrintKeyValue("任务ID", taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
text, err := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &result); err != nil {
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
status, _ := result["status"].(string)
|
||||
message, _ := result["message"].(string)
|
||||
|
||||
switch strings.ToLower(status) {
|
||||
case "completed", "processing":
|
||||
deps.Out.PrintJSON(result)
|
||||
return nil
|
||||
default:
|
||||
deps.Out.PrintJSON(result)
|
||||
if message != "" {
|
||||
return fmt.Errorf("导入任务失败 (status=%s): %s", status, message)
|
||||
}
|
||||
return fmt.Errorf("导入任务失败 (status=%s)", status)
|
||||
}
|
||||
return runImportGetCommand(cmd, docImportFlowConfig())
|
||||
},
|
||||
}
|
||||
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
|
||||
@@ -3237,63 +3075,6 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er
|
||||
return "", fmt.Errorf("导出任务超时:已轮询 %d 次仍在处理中 (jobId=%s),请稍后使用 dws doc export get --job-id %s 手动查询", maxPolls, jobID, jobID)
|
||||
}
|
||||
|
||||
// pollDocImportTask polls the import task status with progressive backoff.
|
||||
func pollDocImportTask(ctx context.Context, taskID string) (map[string]any, error) {
|
||||
const maxPolls = 30
|
||||
|
||||
pollInterval := func(attempt int) time.Duration {
|
||||
switch {
|
||||
case attempt <= 5:
|
||||
return 2 * time.Second
|
||||
case attempt <= 10:
|
||||
return 5 * time.Second
|
||||
case attempt <= 20:
|
||||
return 10 * time.Second
|
||||
default:
|
||||
return 15 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxPolls; attempt++ {
|
||||
interval := pollInterval(attempt)
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, ctx.Err())
|
||||
case <-time.After(interval):
|
||||
}
|
||||
|
||||
text, queryErr := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID})
|
||||
if queryErr != nil {
|
||||
return nil, fmt.Errorf("查询导入任务失败 (taskId=%s): %w", taskID, queryErr)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if parseErr := json.Unmarshal([]byte(text), &result); parseErr != nil {
|
||||
return nil, fmt.Errorf("解析查询结果失败 (taskId=%s): %w", taskID, parseErr)
|
||||
}
|
||||
|
||||
status, _ := result["status"].(string)
|
||||
switch strings.ToLower(status) {
|
||||
case "completed":
|
||||
return result, nil
|
||||
case "processing":
|
||||
continue
|
||||
case "failed":
|
||||
message, _ := result["message"].(string)
|
||||
if message != "" {
|
||||
return nil, fmt.Errorf("导入任务失败 (taskId=%s): %s", taskID, message)
|
||||
}
|
||||
return nil, fmt.Errorf("导入任务失败 (taskId=%s)", taskID)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("导入任务超时:已轮询 %d 次仍在处理中 (taskId=%s),请稍后使用 dws doc import get --task-id %s 手动查询", maxPolls, taskID, taskID)
|
||||
}
|
||||
|
||||
// stripDuplicateTitle removes the leading H1 heading from markdown content
|
||||
// when it matches the document name (set via --name). This prevents the title
|
||||
// from appearing twice: once as document metadata and once in the body.
|
||||
|
||||
@@ -181,6 +181,14 @@ func callMCPToolReturnTextOnServer(ctx context.Context, serverID, toolName strin
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// CallMCPToolTextOnServer invokes an MCP tool and returns its raw text response
|
||||
// WITHOUT printing anything, applying the same error classification as the
|
||||
// print path. Exported for the shortcut layer's multi-step ("smart") shortcuts,
|
||||
// which chain several tool calls and need each intermediate result as data.
|
||||
func CallMCPToolTextOnServer(serverID, toolName string, args map[string]any) (string, error) {
|
||||
return callMCPToolReturnTextOnServer(context.Background(), serverID, toolName, args)
|
||||
}
|
||||
|
||||
// callMCPTool 是通用的 MCP 工具调用入口:自动路由 → 调用 → 格式化输出。
|
||||
// 通过 resolveProductID() 自动确定目标 MCP Server,JSON 输出使用默认的 HTML 转义。
|
||||
func callMCPTool(toolName string, args map[string]any) error {
|
||||
@@ -451,9 +459,34 @@ func buildMinimalPATJSON(code string) string {
|
||||
|
||||
// isBusinessError checks if a parsed JSON body represents a business-level error.
|
||||
func isBusinessError(body map[string]any) bool {
|
||||
if _, ok := body["error"].(string); ok {
|
||||
if v, ok := body["error"]; ok {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if strings.TrimSpace(t) != "" {
|
||||
return true
|
||||
}
|
||||
case map[string]any:
|
||||
if len(t) > 0 {
|
||||
return true
|
||||
}
|
||||
case []any:
|
||||
if len(t) > 0 {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
if t != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := body["status"].(string); ok && strings.EqualFold(strings.TrimSpace(v), "error") {
|
||||
return true
|
||||
}
|
||||
for _, key := range []string{"errorCode", "error_code", "code"} {
|
||||
if isErrorCodeValue(body[key]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if v, ok := body["success"].(bool); ok && !v {
|
||||
return true
|
||||
}
|
||||
@@ -463,6 +496,32 @@ func isBusinessError(body map[string]any) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isErrorCodeValue(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
code := strings.TrimSpace(t)
|
||||
if code == "" {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(code) {
|
||||
case "0", "ok", "success", "succeed":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
case float64:
|
||||
return t != 0
|
||||
case int:
|
||||
return t != 0
|
||||
case int64:
|
||||
return t != 0
|
||||
case json.Number:
|
||||
return strings.TrimSpace(t.String()) != "" && t.String() != "0"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isNotLoggedInError checks if the error body indicates missing authentication.
|
||||
func isNotLoggedInError(body map[string]any) bool {
|
||||
if errMsg, ok := body["error"].(string); ok {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageIsBusinessErrorRecognizesRealErrorEnvelopes(t *testing.T) {
|
||||
cases := []map[string]any{
|
||||
{"status": "error", "success": true, "error": map[string]any{"code": "INVALID_BASE_ID"}},
|
||||
{"success": true, "errorCode": "1001"},
|
||||
{"success": true, "error": []any{"failed"}},
|
||||
{"success": false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if !isBusinessError(tc) {
|
||||
t.Fatalf("isBusinessError(%v) = false, want true", tc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsBusinessErrorAllowsSuccessEnvelope(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"success": true,
|
||||
"errorCode": nil,
|
||||
"errorMsg": nil,
|
||||
"result": map[string]any{"ok": true},
|
||||
}
|
||||
if isBusinessError(body) {
|
||||
t.Fatalf("isBusinessError(%v) = true, want false", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsBusinessErrorAllowsCodeZeroSuccessEnvelope(t *testing.T) {
|
||||
body := map[string]any{
|
||||
"success": true,
|
||||
"code": "0",
|
||||
"message": "success",
|
||||
"result": []any{},
|
||||
}
|
||||
if isBusinessError(body) {
|
||||
t.Fatalf("isBusinessError(%v) = true, want false", body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const importMaxFileSize int64 = 20 * 1024 * 1024
|
||||
|
||||
type importPollTimeoutError struct {
|
||||
taskID string
|
||||
maxPolls int
|
||||
}
|
||||
|
||||
func (e *importPollTimeoutError) Error() string {
|
||||
return fmt.Sprintf("导入任务超时:已轮询 %d 次仍在处理中 (taskId=%s)", e.maxPolls, e.taskID)
|
||||
}
|
||||
|
||||
type importPollPolicy struct {
|
||||
maxPolls int
|
||||
interval func(attempt int) time.Duration
|
||||
wait func(context.Context, time.Duration) error
|
||||
}
|
||||
|
||||
type importFlowConfig struct {
|
||||
operation string
|
||||
queryOperation string
|
||||
supportedFormats map[string]bool
|
||||
supportedFormatsText string
|
||||
folderFlags []string
|
||||
workspaceFlags []string
|
||||
requireTarget bool
|
||||
serverID string
|
||||
includeNodeID bool
|
||||
timeoutAsResult bool
|
||||
nextCommand string
|
||||
poll importPollPolicy
|
||||
}
|
||||
|
||||
type preparedImportFile struct {
|
||||
path string
|
||||
name string
|
||||
extension string
|
||||
size int64
|
||||
folder string
|
||||
workspace string
|
||||
}
|
||||
|
||||
func defaultImportPollPolicy() importPollPolicy {
|
||||
return importPollPolicy{
|
||||
maxPolls: 30,
|
||||
interval: func(attempt int) time.Duration {
|
||||
switch {
|
||||
case attempt <= 5:
|
||||
return 2 * time.Second
|
||||
case attempt <= 10:
|
||||
return 5 * time.Second
|
||||
case attempt <= 20:
|
||||
return 10 * time.Second
|
||||
default:
|
||||
return 15 * time.Second
|
||||
}
|
||||
},
|
||||
wait: waitForImportPoll,
|
||||
}
|
||||
}
|
||||
|
||||
func docImportFlowConfig() importFlowConfig {
|
||||
return importFlowConfig{
|
||||
operation: "导入本地文件为在线文档",
|
||||
queryOperation: "查询导入任务结果",
|
||||
supportedFormats: map[string]bool{"docx": true, "doc": true, "xlsx": true, "xls": true, "md": true, "txt": true, "xmind": true, "mark": true},
|
||||
supportedFormatsText: "docx, doc, xlsx, xls, md, txt, xmind, mark",
|
||||
folderFlags: []string{"folder", "folder-id"},
|
||||
workspaceFlags: []string{"workspace", "workspace-id"},
|
||||
nextCommand: "dws doc import get --task-id %s",
|
||||
poll: defaultImportPollPolicy(),
|
||||
}
|
||||
}
|
||||
|
||||
func sheetImportFlowConfig() importFlowConfig {
|
||||
return importFlowConfig{
|
||||
operation: "导入本地表格文件为在线电子表格",
|
||||
queryOperation: "查询表格导入任务结果",
|
||||
supportedFormats: map[string]bool{"xlsx": true, "xls": true},
|
||||
supportedFormatsText: "xlsx, xls",
|
||||
folderFlags: []string{"folder-token", "folder"},
|
||||
workspaceFlags: []string{"workspace"},
|
||||
requireTarget: true,
|
||||
serverID: "doc",
|
||||
includeNodeID: true,
|
||||
timeoutAsResult: true,
|
||||
nextCommand: "dws sheet import get --task-id %s",
|
||||
poll: defaultImportPollPolicy(),
|
||||
}
|
||||
}
|
||||
|
||||
func waitForImportPoll(ctx context.Context, duration time.Duration) error {
|
||||
timer := time.NewTimer(duration)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func importFlagValue(cmd *cobra.Command, names ...string) string {
|
||||
for _, name := range names {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
continue
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString(name); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig) (preparedImportFile, error) {
|
||||
filePath := mustGetFlag(cmd, "file")
|
||||
if filePath == "" && len(args) > 0 {
|
||||
filePath = args[0]
|
||||
}
|
||||
if filePath == "" {
|
||||
return preparedImportFile{}, fmt.Errorf("flag --file is required (or pass file path as argument)")
|
||||
}
|
||||
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return preparedImportFile{}, fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||||
}
|
||||
if fileInfo.IsDir() {
|
||||
return preparedImportFile{}, fmt.Errorf("%s is a directory, not a file", filePath)
|
||||
}
|
||||
if fileInfo.Size() > importMaxFileSize {
|
||||
return preparedImportFile{}, fmt.Errorf("file size %d bytes exceeds 20MB limit", fileInfo.Size())
|
||||
}
|
||||
if fileInfo.Size() == 0 {
|
||||
return preparedImportFile{}, fmt.Errorf("file is empty: %s", filePath)
|
||||
}
|
||||
|
||||
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
if !cfg.supportedFormats[extension] {
|
||||
return preparedImportFile{}, fmt.Errorf("unsupported file format %q, supported: %s", extension, cfg.supportedFormatsText)
|
||||
}
|
||||
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if name == "" {
|
||||
fileName := filepath.Base(filePath)
|
||||
name = strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
||||
}
|
||||
folder := importFlagValue(cmd, cfg.folderFlags...)
|
||||
workspace := importFlagValue(cmd, cfg.workspaceFlags...)
|
||||
if cfg.requireTarget && folder == "" && workspace == "" {
|
||||
return preparedImportFile{}, fmt.Errorf("--folder-token 与 --workspace 至少需要提供一个(导入目标位置)")
|
||||
}
|
||||
|
||||
return preparedImportFile{
|
||||
path: filePath,
|
||||
name: name,
|
||||
extension: extension,
|
||||
size: fileInfo.Size(),
|
||||
folder: folder,
|
||||
workspace: workspace,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (cfg importFlowConfig) callTool(ctx context.Context, toolName string, args map[string]any) (string, error) {
|
||||
if cfg.serverID != "" {
|
||||
return callMCPToolReturnTextOnServer(ctx, cfg.serverID, toolName, args)
|
||||
}
|
||||
return callMCPToolReturnText(ctx, toolName, args)
|
||||
}
|
||||
|
||||
func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) error {
|
||||
file, err := prepareImportFile(cmd, args, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", cfg.operation)
|
||||
deps.Out.PrintKeyValue("文件", file.path)
|
||||
deps.Out.PrintKeyValue("名称", file.name)
|
||||
deps.Out.PrintKeyValue("格式", file.extension)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", file.size))
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo("[1/4] 创建导入会话...")
|
||||
sessionArgs := map[string]any{
|
||||
"fileName": file.name,
|
||||
"suffix": file.extension,
|
||||
"fileSize": file.size,
|
||||
}
|
||||
if file.folder != "" {
|
||||
sessionArgs["targetFolderId"] = file.folder
|
||||
}
|
||||
if file.workspace != "" {
|
||||
sessionArgs["workspaceId"] = file.workspace
|
||||
}
|
||||
|
||||
sessionText, err := cfg.callTool(ctx, "create_import_session", sessionArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建导入会话失败: %w", err)
|
||||
}
|
||||
var sessionResult map[string]any
|
||||
if err := json.Unmarshal([]byte(sessionText), &sessionResult); err != nil {
|
||||
return fmt.Errorf("解析导入会话响应失败: %w", err)
|
||||
}
|
||||
sessionID, _ := sessionResult["sessionId"].(string)
|
||||
uploadURL, _ := sessionResult["uploadUrl"].(string)
|
||||
if sessionID == "" || uploadURL == "" {
|
||||
deps.Out.PrintRaw(sessionText)
|
||||
return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl")
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID))
|
||||
|
||||
deps.Out.PrintInfo("[2/4] 上传文件...")
|
||||
if err := httpPutFile(ctx, uploadURL, nil, file.path, file.size); err != nil {
|
||||
return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err)
|
||||
}
|
||||
deps.Out.PrintInfo(" 文件上传完成")
|
||||
|
||||
deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...")
|
||||
confirmText, err := cfg.callTool(ctx, "confirm_import", map[string]any{"sessionId": sessionID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err)
|
||||
}
|
||||
var confirmResult map[string]any
|
||||
if err := json.Unmarshal([]byte(confirmText), &confirmResult); err != nil {
|
||||
return fmt.Errorf("解析确认导入响应失败: %w", err)
|
||||
}
|
||||
taskID, _ := confirmResult["taskId"].(string)
|
||||
if taskID == "" {
|
||||
deps.Out.PrintRaw(confirmText)
|
||||
return fmt.Errorf("确认导入成功但未返回 taskId")
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID))
|
||||
|
||||
deps.Out.PrintInfo("[4/4] 等待格式转换完成...")
|
||||
result, err := pollImportTask(ctx, taskID, cfg)
|
||||
if err != nil {
|
||||
var timeoutErr *importPollTimeoutError
|
||||
if !errors.As(err, &timeoutErr) {
|
||||
return err
|
||||
}
|
||||
if cfg.timeoutAsResult {
|
||||
deps.Out.PrintInfo(timeoutErr.Error())
|
||||
_ = deps.Out.PrintJSON(map[string]any{
|
||||
"success": false,
|
||||
"timed_out": true,
|
||||
"taskId": taskID,
|
||||
"status": "processing",
|
||||
"next_command": fmt.Sprintf(cfg.nextCommand, taskID),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s,请稍后使用 %s 手动查询", timeoutErr.Error(), fmt.Sprintf(cfg.nextCommand, taskID))
|
||||
}
|
||||
|
||||
documentURL, _ := result["documentUrl"].(string)
|
||||
documentName, _ := result["documentName"].(string)
|
||||
documentType, _ := result["documentType"].(string)
|
||||
finalResult := map[string]any{
|
||||
"success": true,
|
||||
"taskId": taskID,
|
||||
"documentUrl": documentURL,
|
||||
"documentName": documentName,
|
||||
"documentType": documentType,
|
||||
}
|
||||
if cfg.includeNodeID {
|
||||
finalResult["nodeId"] = extractNodeIDFromDocURL(documentURL)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
|
||||
_ = deps.Out.PrintJSON(finalResult)
|
||||
return nil
|
||||
}
|
||||
|
||||
func runImportGetCommand(cmd *cobra.Command, cfg importFlowConfig) error {
|
||||
taskID := mustGetFlag(cmd, "task-id")
|
||||
if taskID == "" {
|
||||
return fmt.Errorf("flag --task-id is required")
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", cfg.queryOperation)
|
||||
deps.Out.PrintKeyValue("任务ID", taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
text, err := cfg.callTool(ctx, "query_import_task", map[string]any{"taskId": taskID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &result); err != nil {
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
status, _ := result["status"].(string)
|
||||
message, _ := result["message"].(string)
|
||||
if strings.EqualFold(status, "completed") {
|
||||
if cfg.includeNodeID {
|
||||
documentURL, _ := result["documentUrl"].(string)
|
||||
result["nodeId"] = extractNodeIDFromDocURL(documentURL)
|
||||
}
|
||||
_ = deps.Out.PrintJSON(result)
|
||||
return nil
|
||||
}
|
||||
if strings.EqualFold(status, "processing") {
|
||||
_ = deps.Out.PrintJSON(result)
|
||||
return nil
|
||||
}
|
||||
|
||||
_ = deps.Out.PrintJSON(result)
|
||||
if message != "" {
|
||||
return fmt.Errorf("导入任务失败 (status=%s): %s", status, message)
|
||||
}
|
||||
return fmt.Errorf("导入任务失败 (status=%s)", status)
|
||||
}
|
||||
|
||||
func pollImportTask(ctx context.Context, taskID string, cfg importFlowConfig) (map[string]any, error) {
|
||||
poll := cfg.poll
|
||||
if poll.maxPolls <= 0 || poll.interval == nil || poll.wait == nil {
|
||||
poll = defaultImportPollPolicy()
|
||||
}
|
||||
for attempt := 1; attempt <= poll.maxPolls; attempt++ {
|
||||
interval := poll.interval(attempt)
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, poll.maxPolls, interval))
|
||||
if err := poll.wait(ctx, interval); err != nil {
|
||||
return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, err)
|
||||
}
|
||||
|
||||
text, err := cfg.callTool(ctx, "query_import_task", map[string]any{"taskId": taskID})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询导入任务失败 (taskId=%s): %w", taskID, err)
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &result); err != nil {
|
||||
return nil, fmt.Errorf("解析查询结果失败 (taskId=%s): %w", taskID, err)
|
||||
}
|
||||
status, _ := result["status"].(string)
|
||||
switch strings.ToLower(status) {
|
||||
case "completed":
|
||||
return result, nil
|
||||
case "processing":
|
||||
continue
|
||||
case "failed":
|
||||
message, _ := result["message"].(string)
|
||||
if message != "" {
|
||||
return nil, fmt.Errorf("导入任务失败 (taskId=%s): %s", taskID, message)
|
||||
}
|
||||
return nil, fmt.Errorf("导入任务失败 (taskId=%s)", taskID)
|
||||
}
|
||||
}
|
||||
return nil, &importPollTimeoutError{taskID: taskID, maxPolls: poll.maxPolls}
|
||||
}
|
||||
|
||||
func extractNodeIDFromDocURL(rawURL string) string {
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil || parsed.Path == "" {
|
||||
return ""
|
||||
}
|
||||
nodeID := path.Base(strings.TrimRight(parsed.Path, "/"))
|
||||
if nodeID == "." || nodeID == "/" {
|
||||
return ""
|
||||
}
|
||||
return nodeID
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type qualityEvaluationCall struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type qualityEvaluationCaller struct {
|
||||
calls []qualityEvaluationCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *qualityEvaluationCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, qualityEvaluationCall{productID: productID, toolName: toolName, args: args})
|
||||
switch toolName {
|
||||
case "get_cell_infos":
|
||||
return qualityEvaluationTextResult("null"), nil
|
||||
case "get_todo_detail":
|
||||
taskID, _ := args["taskId"].(string)
|
||||
if taskID == "INVALID" {
|
||||
return nil, errors.New("task not found")
|
||||
}
|
||||
return qualityEvaluationTextResult(`{"success":true,"result":{"todoDetailModel":{"taskId":"` + taskID + `"}}}`), nil
|
||||
case "update_todo_done_status":
|
||||
return qualityEvaluationTextResult(`{"success":true}`), nil
|
||||
case "list_todo_attachment":
|
||||
return qualityEvaluationTextResult(`{"success":true,"attachments":[]}`), nil
|
||||
default:
|
||||
return qualityEvaluationTextResult(`{}`), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (*qualityEvaluationCaller) Format() string { return "json" }
|
||||
func (c *qualityEvaluationCaller) DryRun() bool { return c.dryRun }
|
||||
func (*qualityEvaluationCaller) Fields() string { return "" }
|
||||
func (*qualityEvaluationCaller) JQ() string { return "" }
|
||||
|
||||
func qualityEvaluationTextResult(text string) *edition.ToolResult {
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
|
||||
}
|
||||
|
||||
func executeQualityEvaluationCommand(t *testing.T, product string, caller *qualityEvaluationCaller, cmd *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", product}, args...)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetRangeReadRejectsNullToolResponse(t *testing.T) {
|
||||
for _, command := range []string{"read", "get"} {
|
||||
t.Run(command, func(t *testing.T) {
|
||||
caller := &qualityEvaluationCaller{}
|
||||
err := executeQualityEvaluationCommand(t, "sheet", caller, newSheetCommand(),
|
||||
"range", command, "--node", "INVALID")
|
||||
if err == nil {
|
||||
t.Fatal("sheet range command accepted a null MCP response")
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_cell_infos" {
|
||||
t.Fatalf("calls = %#v, want one get_cell_infos call", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoCommandsRejectMissingTaskBeforeTargetCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
targetTool string
|
||||
}{
|
||||
{name: "done", args: []string{"task", "done", "--task-id", "INVALID", "--status", "true"}, targetTool: "update_todo_done_status"},
|
||||
{name: "list attachment", args: []string{"task", "list-attachment", "--task-id", "INVALID"}, targetTool: "list_todo_attachment"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &qualityEvaluationCaller{}
|
||||
err := executeQualityEvaluationCommand(t, "todo", caller, newTodoCommand(), tt.args...)
|
||||
if err == nil {
|
||||
t.Fatal("todo command accepted a missing task")
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_todo_detail" {
|
||||
t.Fatalf("calls = %#v, want only get_todo_detail preflight before %s", caller.calls, tt.targetTool)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoCommandsPreflightExistingTask(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
targetTool string
|
||||
}{
|
||||
{name: "done", args: []string{"task", "done", "--task-id", "12345", "--status", "true"}, targetTool: "update_todo_done_status"},
|
||||
{name: "list attachment", args: []string{"task", "list-attachment", "--task-id", "12345"}, targetTool: "list_todo_attachment"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &qualityEvaluationCaller{}
|
||||
if err := executeQualityEvaluationCommand(t, "todo", caller, newTodoCommand(), tt.args...); err != nil {
|
||||
t.Fatalf("todo command returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].toolName != "get_todo_detail" || caller.calls[1].toolName != tt.targetTool {
|
||||
t.Fatalf("calls = %#v, want get_todo_detail then %s", caller.calls, tt.targetTool)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageTodoTaskPreflightIsSkippedForDryRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "done", args: []string{"task", "done", "--task-id", "12345", "--status", "true"}},
|
||||
{name: "list attachment", args: []string{"task", "list-attachment", "--task-id", "12345"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &qualityEvaluationCaller{dryRun: true}
|
||||
if err := executeQualityEvaluationCommand(t, "todo", caller, newTodoCommand(), tt.args...); err != nil {
|
||||
t.Fatalf("todo dry-run returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run made remote calls: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,7 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet chart update 更新浮动图表
|
||||
dws sheet chart delete 删除浮动图表
|
||||
dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载)
|
||||
dws sheet import 导入 xlsx/xls 为在线电子表格
|
||||
dws sheet template list 获取表格模板列表
|
||||
dws sheet template search 搜索表格模板
|
||||
dws sheet template apply 应用表格模板创建新表格文档`,
|
||||
@@ -110,6 +111,7 @@ func newSheetCommand() *cobra.Command {
|
||||
floatImageCmds := newFloatImageCmds()
|
||||
chartCmd := newChartCmd()
|
||||
exportCmd := newExportCmd()
|
||||
importCmd := newSheetImportCmd()
|
||||
templateCmd := newSheetTemplateCmd()
|
||||
tableCmds := newTableCmds()
|
||||
pivotTableCmd := newPivotTableCmd()
|
||||
@@ -134,7 +136,7 @@ func newSheetCommand() *cobra.Command {
|
||||
standaloneCmds = append(standaloneCmds, mediaCmds...)
|
||||
standaloneCmds = append(standaloneCmds, floatImageCmds...)
|
||||
standaloneCmds = append(standaloneCmds, tableCmds...)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, batchUpdateCmd)
|
||||
standaloneCmds = append(standaloneCmds, exportCmd, importCmd, batchUpdateCmd)
|
||||
|
||||
// Register cross-product aliases
|
||||
for _, cmd := range standaloneCmds {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
const sheetImportLong = `将本地表格文件导入为一个新的钉钉在线电子表格,与 dws sheet export(导出)对称。
|
||||
|
||||
支持的文件格式 (按扩展名):
|
||||
xlsx, xls Microsoft Excel 表格
|
||||
|
||||
文件大小限制: 20MB
|
||||
固定导入为电子表格类型;只新建文档,不操作已有表格。
|
||||
|
||||
CLI 内部自动完成全部流程:
|
||||
1. 创建导入会话(获取 OSS 上传凭证)
|
||||
2. 上传文件到 OSS
|
||||
3. 确认导入(触发格式转换)
|
||||
4. 渐进式退避轮询等待完成(最多约 5 分钟)
|
||||
|
||||
如果轮询超时仍未完成,会输出 taskId 供后续手动查询:
|
||||
dws sheet import get --task-id <taskId>`
|
||||
|
||||
func newSheetImportCmd() *cobra.Command {
|
||||
return newSheetImportCmdWithConfig(sheetImportFlowConfig())
|
||||
}
|
||||
|
||||
func newSheetImportCmdWithConfig(cfg importFlowConfig) *cobra.Command {
|
||||
importCmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "导入本地表格文件为在线电子表格 (xlsx / xls)",
|
||||
Long: sheetImportLong,
|
||||
Example: ` # 导入 xlsx 为在线电子表格(默认表格名取文件名)
|
||||
dws sheet import --file ./quote.xlsx --folder-token <FOLDER_TOKEN>
|
||||
|
||||
# 指定目标文件夹与导入后表格名称
|
||||
dws sheet import --file ./report.xls --folder-token <FOLDER_TOKEN> --name "月度报表"
|
||||
|
||||
# 导入到指定知识库
|
||||
dws sheet import --file ./data.xls --workspace <WORKSPACE_ID>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runImportCommand(cmd, args, cfg)
|
||||
},
|
||||
}
|
||||
addSheetImportFlags(importCmd)
|
||||
|
||||
// Schema deliberately binds only runnable leaves. Keep the historical
|
||||
// runnable parent for CLI compatibility, and expose the same action through
|
||||
// a leaf so agents can discover and invoke the import operation.
|
||||
importCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "导入本地表格文件为在线电子表格 (xlsx / xls)",
|
||||
Long: sheetImportLong,
|
||||
Example: ` dws sheet import create --file ./quote.xlsx --folder-token <FOLDER_TOKEN>
|
||||
dws sheet import create --file ./data.xls --workspace <WORKSPACE_ID> --name "月度报表"`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runImportCommand(cmd, args, cfg)
|
||||
},
|
||||
}
|
||||
addSheetImportFlags(importCreateCmd)
|
||||
|
||||
importGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "查询表格导入任务结果(手动兜底)",
|
||||
Long: `根据 taskId 查询表格导入任务的执行结果。
|
||||
通常不需要手动调用,dws sheet import 会自动完成轮询。
|
||||
仅在导入命令超时或中断后,用于手动查询任务状态。
|
||||
|
||||
任务状态:
|
||||
processing 转换中
|
||||
completed 导入成功,返回 documentUrl
|
||||
failed 导入失败`,
|
||||
Example: ` dws sheet import get --task-id <TASK_ID>`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return runImportGetCommand(cmd, cfg)
|
||||
},
|
||||
}
|
||||
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
|
||||
importCmd.AddCommand(importCreateCmd, importGetCmd)
|
||||
return importCmd
|
||||
}
|
||||
|
||||
func addSheetImportFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("file", "", "本地表格文件路径 (必填,支持 xlsx/xls)")
|
||||
cmd.Flags().String("folder-token", "", "目标文件夹 ID 或 URL (与 --workspace 至少传一个)")
|
||||
cmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (与 --folder-token 至少传一个)")
|
||||
cmd.Flags().StringP("name", "n", "", "导入后表格名称 (可选,默认取文件名)")
|
||||
cmd.Flags().String("folder", "", "")
|
||||
_ = cmd.Flags().MarkHidden("folder")
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type sheetImportCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type sheetImportCaller struct {
|
||||
calls []sheetImportCall
|
||||
responses map[string][]string
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *sheetImportCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, sheetImportCall{server: server, tool: tool, args: args})
|
||||
responses := c.responses[tool]
|
||||
if len(responses) == 0 {
|
||||
return nil, fmt.Errorf("unexpected tool call %s/%s", server, tool)
|
||||
}
|
||||
c.responses[tool] = responses[1:]
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: responses[0]}}}, nil
|
||||
}
|
||||
|
||||
func (*sheetImportCaller) Format() string { return "json" }
|
||||
func (c *sheetImportCaller) DryRun() bool { return c.dryRun }
|
||||
func (*sheetImportCaller) Fields() string { return "" }
|
||||
func (*sheetImportCaller) JQ() string { return "" }
|
||||
|
||||
func fastSheetImportConfig() importFlowConfig {
|
||||
cfg := sheetImportFlowConfig()
|
||||
cfg.poll.maxPolls = 3
|
||||
cfg.poll.interval = func(int) time.Duration { return 0 }
|
||||
cfg.poll.wait = func(context.Context, time.Duration) error { return nil }
|
||||
return cfg
|
||||
}
|
||||
|
||||
func executeSheetImportCommand(t *testing.T, caller *sheetImportCaller, cfg importFlowConfig, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
SetHTTPPutFile(nil)
|
||||
})
|
||||
|
||||
InitDeps(caller)
|
||||
var output bytes.Buffer
|
||||
deps.Out.w = &output
|
||||
deps.Out.errW = &output
|
||||
os.Args = append([]string{"dws", "sheet"}, args...)
|
||||
root := newSheetImportCmdWithConfig(cfg)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
return output.String(), err
|
||||
}
|
||||
|
||||
func writeImportFixture(t *testing.T, ext string) string {
|
||||
t.Helper()
|
||||
filePath := filepath.Join(t.TempDir(), "sales."+ext)
|
||||
if err := os.WriteFile(filePath, []byte("test workbook"), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
return filePath
|
||||
}
|
||||
|
||||
func TestSheetImportRejectsInvalidInputBeforeRemoteCall(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
fileExt string
|
||||
args func(string) []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "target is required",
|
||||
fileExt: "xlsx",
|
||||
args: func(path string) []string { return []string{"--file", path} },
|
||||
wantErr: "--folder-token 与 --workspace 至少需要提供一个",
|
||||
},
|
||||
{
|
||||
name: "only excel formats are accepted",
|
||||
fileExt: "csv",
|
||||
args: func(path string) []string { return []string{"--file", path, "--workspace", "ws1"} },
|
||||
wantErr: `unsupported file format "csv", supported: xlsx, xls`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &sheetImportCaller{}
|
||||
_, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), tt.args(writeImportFixture(t, tt.fileExt))...)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("remote calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetImportRunsSharedDocImportFlow(t *testing.T) {
|
||||
caller := &sheetImportCaller{responses: map[string][]string{
|
||||
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`},
|
||||
"confirm_import": {`{"taskId":"task-1"}`},
|
||||
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-1?from=test","documentName":"Sales","documentType":"1"}`},
|
||||
}}
|
||||
SetHTTPPutFile(func(_ context.Context, uploadURL string, _ map[string]string, _ string, size int64) error {
|
||||
if uploadURL != "https://upload.example.test/object" || size <= 0 {
|
||||
return fmt.Errorf("unexpected upload url=%s size=%d", uploadURL, size)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
output, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(),
|
||||
"--file", writeImportFixture(t, "xlsx"),
|
||||
"--workspace", "workspace-1",
|
||||
"--name", "Sales",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("sheet import returned error: %v", err)
|
||||
}
|
||||
|
||||
wantTools := []string{"create_import_session", "confirm_import", "query_import_task"}
|
||||
if len(caller.calls) != len(wantTools) {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
for i, call := range caller.calls {
|
||||
if call.server != "doc" || call.tool != wantTools[i] {
|
||||
t.Fatalf("call[%d] = %#v, want doc/%s", i, call, wantTools[i])
|
||||
}
|
||||
}
|
||||
wantSession := map[string]any{
|
||||
"fileName": "Sales",
|
||||
"suffix": "xlsx",
|
||||
"fileSize": int64(len("test workbook")),
|
||||
"workspaceId": "workspace-1",
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, wantSession) {
|
||||
t.Fatalf("session args = %#v, want %#v", caller.calls[0].args, wantSession)
|
||||
}
|
||||
|
||||
if !strings.Contains(output, `"nodeId": "node-1"`) || !strings.Contains(output, `"success": true`) {
|
||||
t.Fatalf("output missing success contract:\n%s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetImportCreateLeafMatchesLegacyDryRun(t *testing.T) {
|
||||
filePath := writeImportFixture(t, "xlsx")
|
||||
legacy, err := executeSheetImportCommand(t, &sheetImportCaller{dryRun: true}, fastSheetImportConfig(),
|
||||
"--file", filePath, "--workspace", "workspace-1", "--name", "Sales")
|
||||
if err != nil {
|
||||
t.Fatalf("legacy sheet import dry-run returned error: %v", err)
|
||||
}
|
||||
leaf, err := executeSheetImportCommand(t, &sheetImportCaller{dryRun: true}, fastSheetImportConfig(),
|
||||
"create", "--file", filePath, "--workspace", "workspace-1", "--name", "Sales")
|
||||
if err != nil {
|
||||
t.Fatalf("sheet import create dry-run returned error: %v", err)
|
||||
}
|
||||
if leaf != legacy {
|
||||
t.Fatalf("create leaf output differs from legacy entry:\nlegacy:\n%s\nleaf:\n%s", legacy, leaf)
|
||||
}
|
||||
|
||||
root := newSheetImportCmdWithConfig(fastSheetImportConfig())
|
||||
create, _, err := root.Find([]string{"create"})
|
||||
if err != nil || create == nil {
|
||||
t.Fatalf("find create leaf: command=%v err=%v", create, err)
|
||||
}
|
||||
if !create.Runnable() || create.HasSubCommands() {
|
||||
t.Fatalf("create command must be a runnable leaf: runnable=%v hasSubcommands=%v", create.Runnable(), create.HasSubCommands())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetImportTimeoutIsStructuredSuccessExit(t *testing.T) {
|
||||
cfg := fastSheetImportConfig()
|
||||
cfg.poll.maxPolls = 2
|
||||
caller := &sheetImportCaller{responses: map[string][]string{
|
||||
"create_import_session": {`{"sessionId":"session-1","uploadUrl":"https://upload.example.test/object"}`},
|
||||
"confirm_import": {`{"taskId":"task-1"}`},
|
||||
"query_import_task": {
|
||||
`{"status":"processing"}`,
|
||||
`{"status":"processing"}`,
|
||||
},
|
||||
}}
|
||||
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
|
||||
|
||||
output, err := executeSheetImportCommand(t, caller, cfg,
|
||||
"--file", writeImportFixture(t, "xls"), "--folder-token", "folder-1")
|
||||
if err != nil {
|
||||
t.Fatalf("timeout should exit successfully, got %v", err)
|
||||
}
|
||||
for _, fragment := range []string{
|
||||
`"success": false`,
|
||||
`"timed_out": true`,
|
||||
`"status": "processing"`,
|
||||
`"next_command": "dws sheet import get --task-id task-1"`,
|
||||
} {
|
||||
if !strings.Contains(output, fragment) {
|
||||
t.Fatalf("output missing %s:\n%s", fragment, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSheetImportGetAddsNodeIDAndUsesDocServer(t *testing.T) {
|
||||
caller := &sheetImportCaller{responses: map[string][]string{
|
||||
"query_import_task": {`{"status":"completed","documentUrl":"https://alidocs.dingtalk.com/i/nodes/node-2/","documentType":"1"}`},
|
||||
}}
|
||||
output, err := executeSheetImportCommand(t, caller, fastSheetImportConfig(), "get", "--task-id", "task-2")
|
||||
if err != nil {
|
||||
t.Fatalf("sheet import get returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].server != "doc" || caller.calls[0].tool != "query_import_task" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, map[string]any{"taskId": "task-2"}) {
|
||||
t.Fatalf("args = %#v", caller.calls[0].args)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
jsonStart := strings.Index(output, "{")
|
||||
if jsonStart < 0 || json.Unmarshal([]byte(output[jsonStart:]), &result) != nil {
|
||||
t.Fatalf("invalid JSON output:\n%s", output)
|
||||
}
|
||||
if result["nodeId"] != "node-2" {
|
||||
t.Fatalf("nodeId = %#v, want node-2", result["nodeId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocImportConfigPreservesExistingContract(t *testing.T) {
|
||||
cfg := docImportFlowConfig()
|
||||
for _, ext := range []string{"docx", "doc", "xlsx", "xls", "md", "txt", "xmind", "mark"} {
|
||||
if !cfg.supportedFormats[ext] {
|
||||
t.Errorf("doc import no longer supports %s", ext)
|
||||
}
|
||||
}
|
||||
if cfg.requireTarget || cfg.includeNodeID || cfg.timeoutAsResult {
|
||||
t.Fatalf("doc import compatibility changed: %#v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocImportDryRunStillAcceptsMarkdownWithoutTarget(t *testing.T) {
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
|
||||
caller := &sheetImportCaller{dryRun: true}
|
||||
InitDeps(caller)
|
||||
var output bytes.Buffer
|
||||
deps.Out.w = &output
|
||||
filePath := writeImportFixture(t, "md")
|
||||
os.Args = []string{"dws", "doc", "import", "--file", filePath}
|
||||
root := newDocCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs([]string{"import", "--file", filePath})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("doc import dry-run changed behavior: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run made %d remote calls", len(caller.calls))
|
||||
}
|
||||
if !strings.Contains(output.String(), "导入本地文件为在线文档") || !strings.Contains(output.String(), "md") {
|
||||
t.Fatalf("unexpected dry-run output:\n%s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractNodeIDFromDocURL(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"https://alidocs.dingtalk.com/i/nodes/node-1": "node-1",
|
||||
"https://alidocs.dingtalk.com/i/nodes/node-2/?from=test": "node-2",
|
||||
"https://alidocs.dingtalk.com/i/nodes/node-3#sheet=Sheet1": "node-3",
|
||||
"": "",
|
||||
}
|
||||
for rawURL, want := range tests {
|
||||
if got := extractNodeIDFromDocURL(rawURL); got != want {
|
||||
t.Errorf("extractNodeIDFromDocURL(%q) = %q, want %q", rawURL, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,13 @@ func callMCPToolCellInfos(toolArgs map[string]any) error {
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
if parsed == nil {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: "get_cell_infos returned a null response",
|
||||
Suggestion: "请确认 --node 指向当前用户可访问的钉钉在线表格",
|
||||
}
|
||||
}
|
||||
// 清理空 dataValidation / hyperlink
|
||||
if cells, ok := parsed["cells"].([]any); ok {
|
||||
for _, row := range cells {
|
||||
|
||||
@@ -22,6 +22,33 @@ import (
|
||||
|
||||
const todoListPageSizeMax = 20
|
||||
|
||||
func ensureTodoTaskExists(ctx context.Context, taskID string) error {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "todo", "get_todo_detail", map[string]any{
|
||||
"taskId": taskID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("待办任务 %q 不存在或不可访问: %w", taskID, err)
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &body); err != nil {
|
||||
return fmt.Errorf("校验待办任务 %q 时无法解析详情响应: %w", taskID, err)
|
||||
}
|
||||
result, ok := body["result"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("待办任务 %q 不存在或详情响应缺少 result", taskID)
|
||||
}
|
||||
detail, ok := result["todoDetailModel"].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("待办任务 %q 不存在或详情响应缺少 todoDetailModel", taskID)
|
||||
}
|
||||
returnedTaskID := stringFromJSONScalar(detail["taskId"])
|
||||
if returnedTaskID == "" || returnedTaskID != taskID {
|
||||
return fmt.Errorf("待办任务 %q 不存在或详情响应中的 taskId 不匹配", taskID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTodoCommand() *cobra.Command {
|
||||
todoCmd := &cobra.Command{
|
||||
Use: "todo",
|
||||
@@ -231,8 +258,14 @@ func newTodoCommand() *cobra.Command {
|
||||
if err := validateRequiredFlags(cmd, "task-id", "status"); err != nil {
|
||||
return err
|
||||
}
|
||||
taskID := mustGetFlag(cmd, "task-id")
|
||||
if !deps.Caller.DryRun() {
|
||||
if err := ensureTodoTaskExists(cmd.Context(), taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return callMCPTool("update_todo_done_status", map[string]any{
|
||||
"taskId": mustGetFlag(cmd, "task-id"),
|
||||
"taskId": taskID,
|
||||
"isDone": mustGetFlag(cmd, "status"),
|
||||
})
|
||||
},
|
||||
@@ -535,10 +568,15 @@ func newTodoCommand() *cobra.Command {
|
||||
if err := validateRequiredFlags(cmd, "task-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
taskId := mustGetFlag(cmd, "task-id")
|
||||
taskID := mustGetFlag(cmd, "task-id")
|
||||
if !deps.Caller.DryRun() {
|
||||
if err := ensureTodoTaskExists(cmd.Context(), taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return callMCPTool("list_todo_attachment", map[string]any{
|
||||
"todoAttachmentListRequest": map[string]any{
|
||||
"taskId": taskId,
|
||||
"taskId": taskID,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@@ -98,6 +98,12 @@ func TestWukongSyncDocCommands(t *testing.T) {
|
||||
|
||||
func TestWukongSyncSheetCommands(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
importCmd := requireWukongSyncCommand(t, root, "import")
|
||||
requireWukongSyncFlags(t, importCmd, "file", "folder-token", "workspace", "name", "folder")
|
||||
|
||||
importGetCmd := requireWukongSyncCommand(t, root, "import", "get")
|
||||
requireWukongSyncFlags(t, importGetCmd, "task-id")
|
||||
|
||||
tableGetCmd := requireWukongSyncCommand(t, root, "table-get")
|
||||
requireWukongSyncFlags(t, tableGetCmd, "node", "sheet-id", "range", "no-header")
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
||||
// 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 builtin aggregates all built-in shortcut service packages via blank
|
||||
// imports so their init() registrations run, then re-exports the compiled cobra
|
||||
// commands. The host application depends only on this package, keeping the
|
||||
// service packages free to import the core shortcut package without a cycle.
|
||||
//
|
||||
// Add a blank import here when a new service package is generated under
|
||||
// internal/shortcut/<service>/.
|
||||
package builtin
|
||||
|
||||
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/aitable"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/attendance"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendar"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/contact"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devapp"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/ding"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/doc"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/drive"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/mail"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutes"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/oa"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/report"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/sheet"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/smart"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/wiki"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// Commands returns all built-in shortcut commands, grouped by service, ready to
|
||||
// be merged into the root command tree.
|
||||
func Commands() []*cobra.Command {
|
||||
return shortcut.Commands()
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
// 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 builtin_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// fakeCaller implements edition.ToolCaller. It records the (product, tool, args)
|
||||
// of the last CallTool so the coverage test can assert what MCP call a shortcut
|
||||
// assembled — WITHOUT any network I/O or side effects. This lets us exercise
|
||||
// every shortcut, including write/delete commands, safely.
|
||||
type fakeCaller struct {
|
||||
called bool
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
dryRun bool
|
||||
payload int
|
||||
}
|
||||
|
||||
func (f *fakeCaller) reset() { f.called, f.product, f.tool, f.args = false, "", "", nil }
|
||||
|
||||
func (f *fakeCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
f.called, f.product, f.tool, f.args = true, product, tool, args
|
||||
payload, err := json.Marshal(fakePayload(f.payload))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: string(payload)}}}, nil
|
||||
}
|
||||
func (f *fakeCaller) Format() string { return "json" }
|
||||
func (f *fakeCaller) DryRun() bool { return f.dryRun }
|
||||
func (f *fakeCaller) Fields() string { return "" }
|
||||
func (f *fakeCaller) JQ() string { return "" }
|
||||
|
||||
// realToolSet scans the helper sources (the ground truth) and returns the set of
|
||||
// snake_case identifiers found there. Every tool a shortcut invokes must appear
|
||||
// in this set; anything else would be a hallucinated tool name.
|
||||
func realToolSet(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
files, err := filepath.Glob("../../../internal/helpers/*.go")
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("cannot locate helper sources: %v (found %d)", err, len(files))
|
||||
}
|
||||
// Two oracles unioned:
|
||||
// - every ASCII word token in the helper sources (guarantees any
|
||||
// snake_case / camelCase tool name present anywhere is captured);
|
||||
// - quoted literals containing CJK, to cover Chinese const tool names
|
||||
// (e.g. minutes' "执行听记指令-发起AI听记录音").
|
||||
wordRe := regexp.MustCompile(`[A-Za-z][A-Za-z0-9_]{2,}`)
|
||||
cjkRe := regexp.MustCompile(`"([^"]*\p{Han}[^"]*)"`)
|
||||
set := map[string]bool{}
|
||||
for _, f := range files {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
s := string(data)
|
||||
for _, m := range wordRe.FindAllString(s, -1) {
|
||||
set[m] = true
|
||||
}
|
||||
for _, m := range cjkRe.FindAllStringSubmatch(s, -1) {
|
||||
set[m[1]] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// synthArgs builds a plausible argument vector for a shortcut: a value for every
|
||||
// declared flag (enum flags get their first allowed value), plus --yes to skip
|
||||
// confirmation on write/high-risk commands.
|
||||
func synthArgs(s shortcut.Shortcut) []string {
|
||||
args := []string{s.Service, s.Command}
|
||||
skipped := map[string]bool{}
|
||||
for _, constraint := range s.Constraints {
|
||||
if constraint.Kind != shortcut.ConstraintExactlyOne && constraint.Kind != shortcut.ConstraintMutuallyExclusive {
|
||||
continue
|
||||
}
|
||||
for _, name := range constraint.Flags[1:] {
|
||||
skipped[name] = true
|
||||
}
|
||||
}
|
||||
for _, f := range s.Flags {
|
||||
if skipped[f.Name] {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case len(f.Enum) > 0:
|
||||
args = append(args, "--"+f.Name, f.Enum[0])
|
||||
case f.Type == shortcut.FlagBool:
|
||||
args = append(args, "--"+f.Name)
|
||||
default:
|
||||
args = append(args, "--"+f.Name, synthFlagValue(s, f))
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func synthFlagValue(s shortcut.Shortcut, f shortcut.Flag) string {
|
||||
name := strings.ToLower(f.Name)
|
||||
desc := strings.ToLower(f.Desc)
|
||||
if f.Type == shortcut.FlagInt {
|
||||
switch name {
|
||||
case "from":
|
||||
return "9"
|
||||
case "to":
|
||||
return "18"
|
||||
default:
|
||||
return "1"
|
||||
}
|
||||
}
|
||||
if name == "pair" {
|
||||
return "原文=>替换"
|
||||
}
|
||||
if name == "category-ids" {
|
||||
return "1"
|
||||
}
|
||||
if name == "role-types" {
|
||||
return "creator"
|
||||
}
|
||||
if name == "num" {
|
||||
return "1"
|
||||
}
|
||||
if name == "type" && s.Service == "attendance" && s.Command == "+get-approve-template" {
|
||||
return "leave"
|
||||
}
|
||||
if s.Service == "attendance" {
|
||||
switch {
|
||||
case s.Command == "+list-approve" && name == "types":
|
||||
return "overtime"
|
||||
case s.Command == "+import-schedule" && name == "schedules":
|
||||
return `[{"userId":"user-1","workDate":"2026-04-01 09:00:00","classId":1,"isRest":"N"}]`
|
||||
case s.Command == "+create-class" && name == "class-vo":
|
||||
return `{"sections":[{"times":[{"checkType":"OnDuty","checkTime":"09:00","across":0},{"checkType":"OffDuty","checkTime":"18:00","across":0}]}]}`
|
||||
case s.Command == "+create-group" && name == "group-vo":
|
||||
return `{"defaultClassId":1,"workDayClassList":[0,1,1,1,1,1,0]}`
|
||||
case s.Command == "+update-group" && name == "class-ids":
|
||||
return `[1]`
|
||||
case s.Command == "+query-report-data" && name == "columns":
|
||||
return "1"
|
||||
case s.Command == "+update-leave-type" && name == "visibility-rules":
|
||||
return `[{"type":"dept","visible":["-1"]}]`
|
||||
}
|
||||
}
|
||||
if strings.Contains(desc, "json") || strings.Contains(name, "config") || strings.HasSuffix(name, "-vo") {
|
||||
switch name {
|
||||
case "records":
|
||||
return `[{"recordId":"rec-1","cells":{"name":"test"}}]`
|
||||
case "schedules":
|
||||
return `[{}]`
|
||||
case "filters", "sub-roles", "visibility-rules":
|
||||
return `[]`
|
||||
default:
|
||||
return `{}`
|
||||
}
|
||||
}
|
||||
if name == "start" || name == "end" || name == "date" {
|
||||
if s.Service == "attendance" {
|
||||
if strings.Contains(desc, "hh:mm:ss") || strings.Contains(desc, "时分秒") {
|
||||
if name == "end" {
|
||||
return "2026-04-01 18:00:00"
|
||||
}
|
||||
return "2026-04-01 09:00:00"
|
||||
}
|
||||
if name == "end" {
|
||||
return "2026-04-02"
|
||||
}
|
||||
return "2026-04-01"
|
||||
}
|
||||
if name == "end" {
|
||||
return "2026-04-01T18:00:00+08:00"
|
||||
}
|
||||
return "2026-04-01T09:00:00+08:00"
|
||||
}
|
||||
if name == "modified-start" {
|
||||
return "2026-04-01T09:00:00+08:00"
|
||||
}
|
||||
if name == "modified-end" {
|
||||
return "2026-04-01T18:00:00+08:00"
|
||||
}
|
||||
if strings.Contains(name, "email") {
|
||||
return "user@example.com"
|
||||
}
|
||||
if strings.Contains(name, "url") {
|
||||
return "https://example.com/file"
|
||||
}
|
||||
return "x"
|
||||
}
|
||||
|
||||
func fakePayload(mode int) map[string]any {
|
||||
item := map[string]any{
|
||||
"id": "id-1", "uuid": "uuid-1", "taskUuid": "task-1", "taskId": "task-1",
|
||||
"todoTaskId": "todo-1", "recordId": "rec-1", "baseId": "base-1", "tableId": "table-1",
|
||||
"fieldId": "field-1", "viewId": "view-1", "userId": "user-1", "deptId": "dept-1",
|
||||
"spaceId": "space-1", "nodeId": "node-1", "dentryUuid": "file-1", "eventId": "event-1",
|
||||
"roomId": "room-1", "calendarId": "cal-1", "messageId": "msg-1", "reportId": "report-1",
|
||||
"processInstanceId": "process-1", "openConversationId": "cid-1", "conversationId": "cid-1",
|
||||
"name": "测试名称", "title": "测试标题", "subject": "测试主题", "summary": "测试摘要",
|
||||
"description": "测试描述", "text": "测试内容", "content": "测试内容", "status": "NORMAL",
|
||||
"type": "text", "email": "user@example.com", "mobile": "13800000000", "priority": 1,
|
||||
"startTime": int64(1775005200000), "endTime": int64(1775037600000), "createTime": int64(1775005200000),
|
||||
"planFinishDate": int64(1775037600000), "fileSize": int64(128), "cells": map[string]any{"name": "test"},
|
||||
"sender": map[string]any{"userId": "user-1", "name": "测试用户"},
|
||||
"creator": map[string]any{"userId": "user-1", "name": "测试用户"},
|
||||
"owner": map[string]any{"userId": "user-1", "name": "测试用户"},
|
||||
}
|
||||
list := []any{item}
|
||||
container := map[string]any{
|
||||
"ok": true, "hasMore": false, "nextCursor": "", "nextToken": "", "total": 1,
|
||||
}
|
||||
for _, key := range []string{
|
||||
"list", "items", "records", "users", "departments", "spaces", "tables", "fields", "views",
|
||||
"messages", "events", "rooms", "calendars", "participants", "attendees", "approvals", "tasks",
|
||||
"todos", "mails", "reports", "minutes", "documents", "files", "nodes", "groups", "members",
|
||||
} {
|
||||
container[key] = list
|
||||
}
|
||||
for key, value := range item {
|
||||
container[key] = value
|
||||
}
|
||||
switch mode {
|
||||
case 1:
|
||||
container["result"] = list
|
||||
container["data"] = list
|
||||
return container
|
||||
case 2:
|
||||
return map[string]any{"ok": true, "result": container, "data": container}
|
||||
case 3:
|
||||
return map[string]any{"ok": true, "result": list, "data": container, "items": list, "list": list}
|
||||
default:
|
||||
return map[string]any{"ok": true}
|
||||
}
|
||||
}
|
||||
|
||||
func setOutputRecursive(cmd *cobra.Command, writer io.Writer) {
|
||||
cmd.SetOut(writer)
|
||||
cmd.SetErr(writer)
|
||||
for _, child := range cmd.Commands() {
|
||||
setOutputRecursive(child, writer)
|
||||
}
|
||||
}
|
||||
|
||||
func newCoverageRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.AddCommand(builtin.Commands()...)
|
||||
setOutputRecursive(root, io.Discard)
|
||||
return root
|
||||
}
|
||||
|
||||
func silenceProcessOutput(t *testing.T) {
|
||||
t.Helper()
|
||||
file, err := os.Create(filepath.Join(t.TempDir(), "shortcut-output.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldStdout, oldStderr := os.Stdout, os.Stderr
|
||||
os.Stdout, os.Stderr = file, file
|
||||
t.Cleanup(func() {
|
||||
os.Stdout, os.Stderr = oldStdout, oldStderr
|
||||
_ = file.Close()
|
||||
})
|
||||
}
|
||||
|
||||
func TestAllShortcutConstraintsAreDiscoverableAndBound(t *testing.T) {
|
||||
for _, s := range shortcut.All() {
|
||||
name := s.Service + " " + s.Command
|
||||
flags := make(map[string]bool, len(s.Flags))
|
||||
for _, flag := range s.Flags {
|
||||
flags[flag.Name] = true
|
||||
}
|
||||
hasCustom := false
|
||||
for _, constraint := range s.Constraints {
|
||||
if len(constraint.Flags) == 0 {
|
||||
t.Errorf("%s: %s constraint has no flags", name, constraint.Kind)
|
||||
}
|
||||
for _, flag := range constraint.Flags {
|
||||
if !flags[flag] {
|
||||
t.Errorf("%s: %s constraint references unknown --%s", name, constraint.Kind, flag)
|
||||
}
|
||||
}
|
||||
if constraint.Kind == shortcut.ConstraintCustom {
|
||||
hasCustom = true
|
||||
if strings.TrimSpace(constraint.Description) == "" {
|
||||
t.Errorf("%s: custom constraint has no description", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if s.Validate != nil && !hasCustom {
|
||||
t.Errorf("%s: Validate is not published as a custom constraint", name)
|
||||
}
|
||||
if hasCustom && s.Validate == nil {
|
||||
t.Errorf("%s: custom constraint has no Validate implementation", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllShortcutsAssemble drives every registered shortcut end-to-end through
|
||||
// the cobra tree with synthesized inputs and asserts each one is healthy:
|
||||
// - it either assembles an MCP call with a real (non-hallucinated) tool name,
|
||||
// - or it is rejected by its own validation (proving the plumbing ran),
|
||||
// - and it never panics or silently no-ops.
|
||||
func TestAllShortcutsAssemble(t *testing.T) {
|
||||
silenceProcessOutput(t)
|
||||
fake := &fakeCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
|
||||
root := newCoverageRoot()
|
||||
|
||||
real := realToolSet(t)
|
||||
all := shortcut.All()
|
||||
if len(all) == 0 {
|
||||
t.Fatal("no shortcuts registered")
|
||||
}
|
||||
|
||||
var assembled, validated, failed int
|
||||
var validatedNames []string
|
||||
for _, s := range all {
|
||||
name := s.Service + " " + s.Command
|
||||
args := append(synthArgs(s), "--yes")
|
||||
|
||||
fake.reset()
|
||||
err := func() (e error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("PANIC in %s: %v", name, r)
|
||||
e = nil
|
||||
}
|
||||
}()
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}()
|
||||
|
||||
switch {
|
||||
case fake.called:
|
||||
assembled++
|
||||
if fake.tool == "" {
|
||||
t.Errorf("%s: assembled call with EMPTY tool name", name)
|
||||
} else if !real[fake.tool] {
|
||||
t.Errorf("%s: tool %q not found in any helper (hallucinated?)", name, fake.tool)
|
||||
}
|
||||
if fake.product == "" {
|
||||
t.Errorf("%s: assembled call with EMPTY product", name)
|
||||
}
|
||||
case err != nil:
|
||||
// Rejected by required/enum/Validate before dispatch — plumbing OK.
|
||||
validated++
|
||||
validatedNames = append(validatedNames, s.Service+s.Command+"="+err.Error())
|
||||
default:
|
||||
failed++
|
||||
t.Errorf("%s: returned nil error but never assembled an MCP call (dead command)", name)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("shortcuts=%d assembled(真实MCP)=%d validated(自校验拦截)=%d failed=%d",
|
||||
len(all), assembled, validated, failed)
|
||||
t.Logf("validated(自校验拦截) 明细: %s", strings.Join(validatedNames, " "))
|
||||
if assembled == 0 {
|
||||
t.Fatal("no shortcut assembled an MCP call — harness likely broken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsHandleRepresentativeResponseShapes(t *testing.T) {
|
||||
silenceProcessOutput(t)
|
||||
all := shortcut.All()
|
||||
for _, mode := range []int{1, 2, 3} {
|
||||
fake := &fakeCaller{payload: mode}
|
||||
helpers.InitDeps(fake)
|
||||
root := newCoverageRoot()
|
||||
called := 0
|
||||
for _, s := range all {
|
||||
fake.reset()
|
||||
root.SetArgs(append(synthArgs(s), "--yes"))
|
||||
func() {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
t.Errorf("payload mode %d panicked in %s %s: %v", mode, s.Service, s.Command, recovered)
|
||||
}
|
||||
}()
|
||||
_ = root.Execute()
|
||||
}()
|
||||
if fake.called {
|
||||
called++
|
||||
}
|
||||
}
|
||||
if called < 350 {
|
||||
t.Errorf("payload mode %d reached only %d/%d shortcuts", mode, called, len(all))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReplaceBatchDryRunDoesNotCallTool(t *testing.T) {
|
||||
fake := &fakeCaller{dryRun: true}
|
||||
helpers.InitDeps(fake)
|
||||
|
||||
root := newCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"minutes", "+replace-batch", "--id", "task-1",
|
||||
"--pair", "Q2=>第二季度", "--dry-run", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fake.called {
|
||||
t.Fatalf("dry-run called real tool %s/%s with %#v", fake.product, fake.tool, fake.args)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAllToolLiteralsAreReal statically scans every shortcut package source for
|
||||
// CallMCP("tool", ...) literals and asserts each tool exists in the helper
|
||||
// ground truth. This covers the ~55 shortcuts whose own validation blocks the
|
||||
// runtime assemble path, so no shortcut's tool name goes unverified.
|
||||
func TestCrossPlatformCoverageAllToolLiteralsAreReal(t *testing.T) {
|
||||
real := realToolSet(t)
|
||||
srcs, err := filepath.Glob("../*/*.go")
|
||||
if err != nil || len(srcs) == 0 {
|
||||
t.Fatalf("cannot locate shortcut sources: %v", err)
|
||||
}
|
||||
// CallMCP("tool", ...) — 1:1 wrappers; the tool is the 1st arg.
|
||||
callRe := regexp.MustCompile(`CallMCP\("([^"]+)"`)
|
||||
// CallMCPData("product", "tool", ...) — multi-step (smart) shortcuts; the
|
||||
// tool is the 2nd arg.
|
||||
dataRe := regexp.MustCompile(`CallMCPData\("[^"]+",\s*"([^"]+)"`)
|
||||
var checked, bad int
|
||||
check := func(f, tool string) {
|
||||
checked++
|
||||
if !real[tool] {
|
||||
bad++
|
||||
t.Errorf("%s: tool %q not found in any helper (hallucinated?)", filepath.Base(f), tool)
|
||||
}
|
||||
}
|
||||
for _, f := range srcs {
|
||||
if strings.HasSuffix(f, "_test.go") || strings.Contains(f, "/builtin/") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
src := string(data)
|
||||
for _, m := range callRe.FindAllStringSubmatch(src, -1) {
|
||||
check(f, m[1])
|
||||
}
|
||||
for _, m := range dataRe.FindAllStringSubmatch(src, -1) {
|
||||
check(f, m[1])
|
||||
}
|
||||
}
|
||||
t.Logf("tool literals checked=%d hallucinated=%d", checked, bad)
|
||||
}
|
||||
|
||||
// TestAllHaveIntent enforces that every shortcut carries a natural-language
|
||||
// Intent (a fuller "what/when to use" description for discovery and AI-agent
|
||||
// matching), not just the terse one-line Description.
|
||||
func TestCrossPlatformCoverageAllHaveIntent(t *testing.T) {
|
||||
var missing []string
|
||||
for _, s := range shortcut.All() {
|
||||
if strings.TrimSpace(s.Intent) == "" {
|
||||
missing = append(missing, s.Service+" "+s.Command)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("%d shortcut(s) missing Intent (natural-language description):\n %s",
|
||||
len(missing), strings.Join(missing, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoDuplicateCommands guards against two shortcuts colliding on the same
|
||||
// service+command path.
|
||||
func TestCrossPlatformCoverageNoDuplicateCommands(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, s := range shortcut.All() {
|
||||
key := s.Service + " " + s.Command
|
||||
if seen[key] {
|
||||
t.Errorf("duplicate shortcut: %s", key)
|
||||
}
|
||||
seen[key] = true
|
||||
if !strings.HasPrefix(s.Command, "+") {
|
||||
t.Errorf("%s: command must start with '+'", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,730 @@
|
||||
// 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 calendar declares the declarative shortcut (+command) layer for the
|
||||
// DingTalk calendar MCP product. Tool names and parameter keys are copied
|
||||
// verbatim from internal/helpers/calendar.go, the single source of truth for
|
||||
// the real DingTalk MCP tools.
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// parseMillis converts an ISO-8601 / RFC3339 timestamp (e.g.
|
||||
// "2026-03-10T14:00:00+08:00") into Unix epoch milliseconds, matching the
|
||||
// helper's parseISOTimeToMillis behaviour for the millis-based MCP tools.
|
||||
func parseMillis(field, v string) (int64, error) {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid --%s time %q: expected ISO-8601 like 2026-03-10T14:00:00+08:00", field, v)
|
||||
}
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
|
||||
// ── event: 日程 ──────────────────────────────────────────────
|
||||
|
||||
// EventList → list_calendar_events
|
||||
var EventList = shortcut.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+agenda",
|
||||
Product: "calendar",
|
||||
Description: "查询日程列表(不传时间默认查询今天)",
|
||||
Intent: "当你想了解某人(默认自己)在某段时间内的日程安排、看看今天/本周有哪些会时使用;可传 --start/--end 圈定时间范围、--calendar-id 指定日历,返回该区间内的日程列表(含日程 ID,可配合 +get 看详情)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00),默认今天 00:00"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601,默认今天 23:59"},
|
||||
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID (默认 primary 主日历)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标 (上一次返回的 nextCursor)"},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回条数 (默认 100,最大 100)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws calendar +agenda`,
|
||||
`dws calendar +agenda --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00" --limit 50`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
now := time.Now()
|
||||
if rt.Changed("start") {
|
||||
ms, err := parseMillis("start", rt.Str("start"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["startTime"] = ms
|
||||
} else {
|
||||
params["startTime"] = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
|
||||
}
|
||||
if rt.Changed("end") {
|
||||
ms, err := parseMillis("end", rt.Str("end"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params["endTime"] = ms
|
||||
} else {
|
||||
params["endTime"] = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
|
||||
}
|
||||
if rt.Changed("calendar-id") {
|
||||
params["calendarId"] = rt.Str("calendar-id")
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["limit"] = rt.Int("limit")
|
||||
}
|
||||
data, err := rt.CallMCPData("calendar", "list_calendar_events", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events := eventListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(events), "events": events})
|
||||
},
|
||||
}
|
||||
|
||||
// eventListProject reshapes the raw list_calendar_events response into a clean,
|
||||
// stable event list (eventId/summary/start/end/status/location) — the
|
||||
// the clean output projection applied to every list command. The
|
||||
// list container and each field are probed defensively across candidate keys,
|
||||
// since event payloads may nest under result/data/list/items with aliases.
|
||||
func eventListProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
raw := eventListContainer(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := eventListFirst(m, "eventId", "event_id", "id"); ok {
|
||||
row["eventId"] = v
|
||||
}
|
||||
if v, ok := eventListFirst(m, "summary", "title", "name"); ok {
|
||||
row["summary"] = v
|
||||
}
|
||||
if v, ok := eventListFirst(m, "start", "startTime", "start_time", "startDateTime", "start_date_time"); ok {
|
||||
row["start"] = v
|
||||
}
|
||||
if v, ok := eventListFirst(m, "end", "endTime", "end_time", "endDateTime", "end_date_time"); ok {
|
||||
row["end"] = v
|
||||
}
|
||||
if v, ok := eventListFirst(m, "status", "eventStatus", "event_status", "responseStatus", "response_status"); ok {
|
||||
row["status"] = v
|
||||
}
|
||||
if v, ok := eventListFirst(m, "location", "locationName", "location_name"); ok {
|
||||
row["location"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// eventListContainer locates the event slice across candidate wrapper keys,
|
||||
// unwrapping one nested object layer (e.g. result.list) when needed.
|
||||
func eventListContainer(data map[string]any) []any {
|
||||
keys := []string{"result", "data", "list", "items", "events"}
|
||||
for _, k := range keys {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if nested, ok := v.(map[string]any); ok {
|
||||
for _, nk := range keys {
|
||||
if arr, ok := nested[nk].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// eventListFirst returns the first present, non-nil value among candidate keys.
|
||||
func eventListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// EventGet → get_calendar_detail
|
||||
// EventCreate → create_calendar_event
|
||||
// EventUpdate → update_calendar_event
|
||||
// EventDelete → delete_calendar_event
|
||||
// EventSuggest → list_suggested_event_times
|
||||
// EventRespond → respond
|
||||
// ── attendee: 参会人 ──────────────────────────────────────────
|
||||
|
||||
// AttendeeList → get_calendar_participants
|
||||
var AttendeeList = shortcut.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+attendee-list",
|
||||
Product: "calendar",
|
||||
Description: "查看日程参会人",
|
||||
Intent: "当你想知道某个日程都有谁参加、各人的出席响应状态时使用;输入 --event 日程 ID,返回参会人列表(userId 及其接受/拒绝等状态)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "event", Type: shortcut.FlagString, Desc: "日程 ID", Required: true},
|
||||
{Name: "calendar-id", Type: shortcut.FlagString, Desc: "日历 ID (默认 primary 主日历)"},
|
||||
},
|
||||
Tips: []string{`dws calendar +attendee-list --event EVENT_ID`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"eventId": rt.Str("event")}
|
||||
if rt.Changed("calendar-id") {
|
||||
params["calendarId"] = rt.Str("calendar-id")
|
||||
}
|
||||
data, err := rt.CallMCPData("calendar", "get_calendar_participants", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
attendees := attendeeListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(attendees), "attendees": attendees})
|
||||
},
|
||||
}
|
||||
|
||||
// attendeeListProject reshapes the raw get_calendar_participants response into a
|
||||
// clean, stable attendee list (displayName/userId/responseStatus) — the
|
||||
// the clean output projection applied to every list command.
|
||||
// The list container and each field are probed defensively across candidate keys,
|
||||
// since participant payloads may nest under result/data/list/items with aliases.
|
||||
func attendeeListProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
raw := attendeeListContainer(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := attendeeFirst(m, "displayName", "display_name", "name", "userName", "user_name", "nick", "nickName"); ok {
|
||||
row["displayName"] = v
|
||||
}
|
||||
if v, ok := attendeeFirst(m, "userId", "user_id", "id", "staffId", "staff_id", "unionId", "union_id"); ok {
|
||||
row["userId"] = v
|
||||
}
|
||||
if v, ok := attendeeFirst(m, "responseStatus", "response_status", "status", "attendeeStatus", "attendee_status", "responseType", "response"); ok {
|
||||
row["responseStatus"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// attendeeListContainer locates the participant slice across candidate wrapper
|
||||
// keys, unwrapping one nested object layer (e.g. result.list) when needed.
|
||||
func attendeeListContainer(data map[string]any) []any {
|
||||
keys := []string{"result", "data", "list", "items", "attendees", "participants"}
|
||||
for _, k := range keys {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if nested, ok := v.(map[string]any); ok {
|
||||
for _, nk := range keys {
|
||||
if arr, ok := nested[nk].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// attendeeFirst returns the first present, non-nil value among candidate keys.
|
||||
func attendeeFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// AttendeeAdd → add_calendar_participant
|
||||
// AttendeeRemove → remove_calendar_participant
|
||||
// ── room: 会议室 ──────────────────────────────────────────────
|
||||
|
||||
// RoomSearch → search_rooms (按名称模糊搜索,不检查可用性)
|
||||
var RoomSearch = shortcut.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+room-search",
|
||||
Product: "calendar",
|
||||
Description: "按名称模糊搜索会议室(不检查可用性)",
|
||||
Intent: "当你只知道会议室名字、想拿到它的 roomId 以便后续预定时使用;输入 --room-name 名称关键词(建议只填核心专名,去掉“会议室”等后缀),返回名称匹配的会议室列表。它只按名字找、不判断该时段是否空闲,查可用性请用 +room-find。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "room-name", Type: shortcut.FlagString, Desc: "会议室名称(精简核心专名,剔除“会议室”等后缀)", Required: true},
|
||||
},
|
||||
Tips: []string{`dws calendar +room-search --room-name 永澄亭`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"roomName": strings.TrimSpace(rt.Str("room-name"))}
|
||||
data, err := rt.CallMCPData("calendar", "search_rooms", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rooms := roomSearchProject(data)
|
||||
return rt.Output(map[string]any{"count": len(rooms), "rooms": rooms})
|
||||
},
|
||||
}
|
||||
|
||||
// roomSearchProject reshapes the raw search_rooms response into a clean, stable
|
||||
// room list (roomId/roomName/capacity/location) — the output-projection fidelity
|
||||
// the framework applies to every list command. The list container and each field
|
||||
// are probed defensively across candidate keys, since room payloads may nest
|
||||
// under result/data/list/items with aliases.
|
||||
func roomSearchProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
raw := roomSearchContainer(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := roomSearchFirst(m, "roomId", "room_id", "id"); ok {
|
||||
row["roomId"] = v
|
||||
}
|
||||
if v, ok := roomSearchFirst(m, "roomName", "room_name", "name", "summary"); ok {
|
||||
row["roomName"] = v
|
||||
}
|
||||
if v, ok := roomSearchFirst(m, "capacity", "seats", "seatCount", "seat_count"); ok {
|
||||
row["capacity"] = v
|
||||
}
|
||||
if v, ok := roomSearchFirst(m, "location", "floor", "building", "address"); ok {
|
||||
row["location"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// roomSearchContainer locates the room slice across candidate wrapper keys,
|
||||
// unwrapping one nested object layer (e.g. result.list) when needed.
|
||||
func roomSearchContainer(data map[string]any) []any {
|
||||
keys := []string{"result", "data", "list", "items", "rooms"}
|
||||
for _, k := range keys {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if nested, ok := v.(map[string]any); ok {
|
||||
for _, nk := range keys {
|
||||
if arr, ok := nested[nk].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// roomSearchFirst returns the first present, non-nil value among candidate keys.
|
||||
func roomSearchFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// RoomFind → query_available_meeting_room (按时间段查可用会议室)
|
||||
var RoomFind = shortcut.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+room-find",
|
||||
Product: "calendar",
|
||||
Description: "按时间段搜索可用会议室(不传时间默认当前起 1 小时)",
|
||||
Intent: "当你要在某个时间段找一间空闲会议室开会时使用;传入 --start/--end 时间段(须为未来时间,缺省为当前起 1 小时),可加 --available 只看空闲、--group-id/--room-name 缩小范围,返回该时段的会议室及其可用性和 roomId,便于据此 +room-add 预定。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (必须是未来时间)"},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601"},
|
||||
{Name: "available", Type: shortcut.FlagBool, Desc: "仅返回可用会议室"},
|
||||
{Name: "group-id", Type: shortcut.FlagString, Desc: "会议室分组 ID"},
|
||||
{Name: "room-name", Type: shortcut.FlagString, Desc: "会议室名称过滤"},
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页条数 (pageSize)"},
|
||||
{Name: "page", Type: shortcut.FlagString, Desc: "页码 (pageIndex,从 0 开始)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws calendar +room-find --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00"`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
now := time.Now()
|
||||
startStr := rt.Str("start")
|
||||
endStr := rt.Str("end")
|
||||
if startStr == "" {
|
||||
startStr = now.Add(1 * time.Minute).Format(time.RFC3339)
|
||||
}
|
||||
if endStr == "" {
|
||||
endStr = now.Add(1 * time.Hour).Format(time.RFC3339)
|
||||
}
|
||||
startMs, err := parseMillis("start", startStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endMs, err := parseMillis("end", endStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"startTime": startMs,
|
||||
"endTime": endMs,
|
||||
}
|
||||
if rt.Bool("available") {
|
||||
params["needAvailable"] = true
|
||||
}
|
||||
if rt.Changed("group-id") {
|
||||
params["groupId"] = rt.Str("group-id")
|
||||
}
|
||||
if rt.Changed("room-name") {
|
||||
params["roomName"] = strings.TrimSpace(rt.Str("room-name"))
|
||||
}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Str("limit")
|
||||
}
|
||||
if rt.Changed("page") {
|
||||
params["pageIndex"] = rt.Str("page")
|
||||
}
|
||||
return rt.CallMCP("query_available_meeting_room", params)
|
||||
},
|
||||
}
|
||||
|
||||
// RoomAdd → add_meeting_room
|
||||
// RoomRemove → delete_meeting_room
|
||||
// RoomGroups → list_meeting_room_groups
|
||||
var RoomGroups = shortcut.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+room-groups",
|
||||
Product: "calendar",
|
||||
Description: "会议室分组列表",
|
||||
Intent: "当你想按楼层/园区等分组浏览会议室、或需要拿到 groupId 以便在 +room-find 里按分组过滤时使用;返回会议室分组列表,支持 --limit/--page 分页。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagString, Desc: "每页条数 (pageSize)"},
|
||||
{Name: "page", Type: shortcut.FlagString, Desc: "页码 (pageIndex,从 0 开始)"},
|
||||
},
|
||||
Tips: []string{`dws calendar +room-groups`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Changed("limit") {
|
||||
params["pageSize"] = rt.Str("limit")
|
||||
}
|
||||
if rt.Changed("page") {
|
||||
params["pageIndex"] = rt.Str("page")
|
||||
}
|
||||
data, err := rt.CallMCPData("calendar", "list_meeting_room_groups", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := roomGroupsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(groups), "groups": groups})
|
||||
},
|
||||
}
|
||||
|
||||
// roomGroupsProject reshapes the raw list_meeting_room_groups response into a
|
||||
// clean, stable group list (groupId/groupName) — the output-projection fidelity
|
||||
// the framework applies to every list command. The list container and each field
|
||||
// are probed defensively across candidate keys, since group payloads may nest
|
||||
// under result/data/list/items with aliases.
|
||||
func roomGroupsProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
raw := roomGroupsContainer(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := roomGroupsFirst(m, "groupId", "group_id", "id"); ok {
|
||||
row["groupId"] = v
|
||||
}
|
||||
if v, ok := roomGroupsFirst(m, "groupName", "group_name", "name", "summary"); ok {
|
||||
row["groupName"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// roomGroupsContainer locates the group slice across candidate wrapper keys,
|
||||
// unwrapping one nested object layer (e.g. result.list) when needed.
|
||||
func roomGroupsContainer(data map[string]any) []any {
|
||||
keys := []string{"result", "data", "list", "items", "groups"}
|
||||
for _, k := range keys {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if nested, ok := v.(map[string]any); ok {
|
||||
for _, nk := range keys {
|
||||
if arr, ok := nested[nk].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// roomGroupsFirst returns the first present, non-nil value among candidate keys.
|
||||
func roomGroupsFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ── busy: 闲忙 ────────────────────────────────────────────────
|
||||
|
||||
// BusySearch → query_busy_status
|
||||
var BusySearch = shortcut.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+freebusy",
|
||||
Product: "calendar",
|
||||
Description: "查询用户 / 会议室闲忙状态(--users 与 --rooms 至少其一)",
|
||||
Intent: "当你要在约会前确认某些人或会议室在指定时间段是否有空、避免冲突时使用;传入 --start/--end 时间段并至少给出 --users 或 --rooms 其一,返回各对象在该区间的忙/闲时段。只看忙闲结果、不看具体日程内容,需要系统给出建议时段可用 +suggestion。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表 (逗号分隔)"},
|
||||
{Name: "rooms", Type: shortcut.FlagStringSlice, Desc: "会议室 roomId 列表 (逗号分隔)"},
|
||||
{Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601", Required: true},
|
||||
{Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"users", "rooms"}},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws calendar +freebusy --users userId1,userId2 --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T18:00:00+08:00"`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
startMs, err := parseMillis("start", rt.Str("start"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
endMs, err := parseMillis("end", rt.Str("end"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"startTime": startMs,
|
||||
"endTime": endMs,
|
||||
}
|
||||
if len(rt.StrSlice("users")) > 0 {
|
||||
params["userIds"] = rt.StrSlice("users")
|
||||
}
|
||||
if len(rt.StrSlice("rooms")) > 0 {
|
||||
params["roomIds"] = rt.StrSlice("rooms")
|
||||
}
|
||||
return rt.CallMCP("query_busy_status", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ── attachment: 附件 ──────────────────────────────────────────
|
||||
|
||||
// AttachmentAdd → add_attachments
|
||||
// ── acl: 日历访问权限 ─────────────────────────────────────────
|
||||
|
||||
// AclList → list_acls
|
||||
// AclAdd → add_acl
|
||||
// AclDelete → delete_acl
|
||||
// ── book: 日历本 ──────────────────────────────────────────────
|
||||
|
||||
// BookList → list_calendars
|
||||
var BookList = shortcut.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+book-list",
|
||||
Product: "calendar",
|
||||
Description: "查询用户的日历本列表",
|
||||
Intent: "当你想知道自己有哪些日历本(主日历、项目日历、订阅日历等)、或需要拿到某个日历的 calendarId 以便在 +agenda/+create 中指定时使用;无需参数,返回全部日历本列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Tips: []string{`dws calendar +book-list`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("calendar", "list_calendars", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
books := bookListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(books), "calendars": books})
|
||||
},
|
||||
}
|
||||
|
||||
// bookListProject reshapes list_calendars into a clean calendar-book list
|
||||
// (calendarId/summary/privilege/type) — clean output projection.
|
||||
func bookListProject(data map[string]any) []map[string]any {
|
||||
raw, ok := data["result"].([]any)
|
||||
if !ok {
|
||||
return []map[string]any{}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
for _, k := range []string{"calendarId", "summary", "privilege", "type", "description"} {
|
||||
if v, ok := m[k]; ok {
|
||||
row[k] = v
|
||||
}
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BookGet → get_calendar
|
||||
// BookSearch → search_calendar
|
||||
var BookSearch = shortcut.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+book-search",
|
||||
Product: "calendar",
|
||||
Description: "按名称模糊搜索日历本",
|
||||
Intent: "当你只记得日历本名字的一部分、想据此找到对应的 calendarId 时使用;输入 --query 名称关键词,返回名称匹配的日历本列表,便于后续在其它命令里指定 --calendar-id。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "日历本名称关键词", Required: true},
|
||||
},
|
||||
Tips: []string{`dws calendar +book-search --query "项目"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("calendar", "search_calendar", map[string]any{"query": rt.Str("query")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
calendars := bookSearchProject(data)
|
||||
return rt.Output(map[string]any{"count": len(calendars), "calendars": calendars})
|
||||
},
|
||||
}
|
||||
|
||||
// bookSearchProject reshapes the raw search_calendar response into a clean,
|
||||
// stable calendar-book list (calendarId/summary/privilege/type) — output-projection
|
||||
// clean output projection. The list container and each field are probed defensively
|
||||
// across candidate keys, tolerating nesting under result/data/list/items.
|
||||
func bookSearchProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
raw := bookSearchContainer(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := bookSearchFirst(m, "calendarId", "calendar_id", "id"); ok {
|
||||
row["calendarId"] = v
|
||||
}
|
||||
if v, ok := bookSearchFirst(m, "summary", "name", "title"); ok {
|
||||
row["summary"] = v
|
||||
}
|
||||
if v, ok := bookSearchFirst(m, "privilege", "role", "accessRole"); ok {
|
||||
row["privilege"] = v
|
||||
}
|
||||
if v, ok := bookSearchFirst(m, "type", "calendarType", "calendar_type"); ok {
|
||||
row["type"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// bookSearchContainer locates the calendar slice across candidate wrapper keys,
|
||||
// unwrapping one nested object layer (e.g. result.list) when needed.
|
||||
func bookSearchContainer(data map[string]any) []any {
|
||||
keys := []string{"result", "data", "list", "items", "calendars"}
|
||||
for _, k := range keys {
|
||||
v, ok := data[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if nested, ok := v.(map[string]any); ok {
|
||||
for _, nk := range keys {
|
||||
if arr, ok := nested[nk].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// bookSearchFirst returns the first present, non-nil value among candidate keys.
|
||||
func bookSearchFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// BookUpdate → update_calendar
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
EventList,
|
||||
AttendeeList,
|
||||
RoomSearch,
|
||||
RoomFind,
|
||||
RoomGroups,
|
||||
BusySearch,
|
||||
BookList,
|
||||
BookSearch,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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 chat provides declarative shortcuts for the DingTalk chat service
|
||||
// (群聊 / 会话 / 消息 / 机器人). Tool names and parameter keys are copied verbatim
|
||||
// from internal/helpers/chat.go, the single source of truth for the real MCP
|
||||
// tools. Shortcuts route to the correct MCP server via each Shortcut.Product:
|
||||
// - "chat" (default): tools invoked via the plain helper callMCPTool path
|
||||
// - "im": tools invoked via callMCPToolOnServer("im", ...)
|
||||
// - "bot": tools invoked via callMCPToolOnServer("bot", ...)
|
||||
package chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// isOpenID reports whether value looks like an openDingTalkId (starts with D/d),
|
||||
// mirroring helpers.isOpenDingTalkID.
|
||||
func isOpenID(v string) bool {
|
||||
v = strings.TrimSpace(v)
|
||||
return len(v) > 0 && (v[0] == 'D' || v[0] == 'd')
|
||||
}
|
||||
|
||||
// splitIDs partitions a mixed list of userId / openDingTalkId values, mirroring
|
||||
// helpers.splitChatIDValues.
|
||||
func splitIDs(vals []string) (userIDs, openIDs []string) {
|
||||
for _, raw := range vals {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if isOpenID(v) {
|
||||
openIDs = append(openIDs, v)
|
||||
} else {
|
||||
userIDs = append(userIDs, v)
|
||||
}
|
||||
}
|
||||
return userIDs, openIDs
|
||||
}
|
||||
|
||||
// toInt64Slice converts string values to []int64, mirroring helpers.parseCSVInt64.
|
||||
func toInt64Slice(vals []string) ([]int64, error) {
|
||||
out := make([]int64, 0, len(vals))
|
||||
for _, raw := range vals {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid integer %q", v)
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("at least one ID is required")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// BotSearch searches robots created by the current user (search_my_robots, bot).
|
||||
var BotSearch = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+bot-search",
|
||||
Product: "bot",
|
||||
Description: "搜索当前用户自己创建的机器人",
|
||||
Intent: "当你要管理或复用自己创建的机器人(比如查到其 robotCode 以便让它进群或发消息)时使用;按机器人名称模糊搜索,只返回当前用户名下创建的机器人列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Desc: "每页数量"},
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "robotName 模糊匹配"},
|
||||
},
|
||||
Tips: []string{`dws chat +bot-search --page 1 --name "日报"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"currentPage": rt.Int("page")}
|
||||
if rt.Int("size") > 0 {
|
||||
params["pageSize"] = rt.Int("size")
|
||||
}
|
||||
if rt.Str("name") != "" {
|
||||
params["robotName"] = rt.Str("name")
|
||||
}
|
||||
data, err := rt.CallMCPData("bot", "search_my_robots", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
robots := botSearchProject(data)
|
||||
return rt.Output(map[string]any{"count": len(robots), "robots": robots})
|
||||
},
|
||||
}
|
||||
|
||||
// botSearchProject reshapes the raw search_my_robots response into a clean robot
|
||||
// list (robotCode/robotName/status) — clean output projection. The
|
||||
// list container and field names are probed defensively across candidate keys.
|
||||
func botSearchProject(data map[string]any) []map[string]any {
|
||||
raw := botResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := botFirst(m, "robotCode", "robot_code", "code", "id"); ok {
|
||||
row["robotCode"] = v
|
||||
}
|
||||
if v, ok := botFirst(m, "robotName", "robot_name", "name"); ok {
|
||||
row["robotName"] = v
|
||||
}
|
||||
if v, ok := botFirst(m, "status", "robotStatus", "state"); ok {
|
||||
row["status"] = v
|
||||
}
|
||||
if v, ok := botFirst(m, "gmtCreate", "createTime", "create_time"); ok {
|
||||
row["gmtCreate"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// botResolveList locates the list payload inside the response, tolerating a bare
|
||||
// top-level array or nesting under result/data/list/items containers.
|
||||
func botResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "robots", "records"} {
|
||||
v, ok := data[key]
|
||||
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", "robots", "records", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// botFirst returns the first present candidate key's value.
|
||||
func botFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// BotFind searches all available robots (search_bots, bot).
|
||||
var BotFind = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+bot-find",
|
||||
Product: "bot",
|
||||
Description: "搜索全部可用机器人(含他人/官方,返回 openDingTalkId 可发单聊)",
|
||||
Intent: "当你想找到平台上任意可用机器人(含他人创建或官方助手,例如某个日报/审批机器人)以便与其发起单聊时使用;输入关键词,返回含 openDingTalkId 的机器人列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
|
||||
},
|
||||
Tips: []string{`dws chat +bot-find --query "日报"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"keyword": rt.Str("query")}
|
||||
if rt.Int("limit") > 0 {
|
||||
params["limit"] = rt.Int("limit")
|
||||
}
|
||||
if rt.Str("cursor") != "" {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
data, err := rt.CallMCPData("bot", "search_bots", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bots := botFindProject(data)
|
||||
return rt.Output(map[string]any{"count": len(bots), "bots": bots})
|
||||
},
|
||||
}
|
||||
|
||||
// botFindProject reshapes the raw search_bots response into a clean bot list
|
||||
// (openDingTalkId/name/robotCode) — clean output projection. The
|
||||
// list container and field names are probed defensively across candidate keys.
|
||||
func botFindProject(data map[string]any) []map[string]any {
|
||||
raw := botResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := botFirst(m, "openDingTalkId", "open_ding_talk_id", "openId"); ok {
|
||||
row["openDingTalkId"] = v
|
||||
}
|
||||
if v, ok := botFirst(m, "name", "robotName", "botName"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if v, ok := botFirst(m, "robotCode", "robot_code", "code", "id"); ok {
|
||||
row["robotCode"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SearchCommonGroups searches groups shared with given people (search_common_groups, chat server).
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
BotSearch,
|
||||
BotFind,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// ConversationInfo gets conversation info (get_conversation_info, chat server).
|
||||
var ConversationInfo = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-info",
|
||||
Product: "chat",
|
||||
Description: "获取会话信息(群聊传 --group,单聊传 --open-dingtalk-id)",
|
||||
Intent: "当你已有群 openConversationId 或单聊对方 openDingTalkId、需要查看该会话的名称/类型/成员数等基础信息时使用;只读,群聊传 --group、单聊传 --open-dingtalk-id 二选一。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群聊 openConversationId"},
|
||||
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方 openDingTalkId"},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-info --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Str("group") != "" {
|
||||
params["openConversationId"] = rt.Str("group")
|
||||
}
|
||||
if rt.Str("open-dingtalk-id") != "" {
|
||||
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
|
||||
}
|
||||
if len(params) == 0 {
|
||||
return fmt.Errorf("--group 或 --open-dingtalk-id 必填其一")
|
||||
}
|
||||
return rt.CallMCP("get_conversation_info", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ConversationSetTop sets/unsets a conversation top (set_top_conversation, im).
|
||||
var ConversationSetTop = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-set-top",
|
||||
Product: "im",
|
||||
Description: "会话置顶 / 取消置顶(支持单聊/群聊)",
|
||||
Intent: "当你想把某个单聊或群聊置顶到会话列表顶部、或取消其置顶时使用;会实际修改该会话的置顶状态,需传 openConversationId,加 --off 取消置顶。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
{Name: "off", Type: shortcut.FlagBool, Desc: "取消置顶(不传则设置置顶)"},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-set-top --conversation-id <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("set_top_conversation", map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"cid": rt.Str("conversation-id"),
|
||||
"top": !rt.Bool("off"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ConversationMute mutes/unmutes a conversation (update_notification_off, im).
|
||||
var ConversationMute = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-mute",
|
||||
Product: "im",
|
||||
Description: "会话消息免打扰(支持单聊/群聊)",
|
||||
Intent: "当你想对某个会话开启或关闭消息免打扰时使用;会实际更改该会话的免打扰设置,需传 openConversationId,加 --off 表示关闭免打扰。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
{Name: "off", Type: shortcut.FlagBool, Desc: "关闭免打扰(不传则开启免打扰)"},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-mute --conversation-id <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_notification_off", map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"cid": rt.Str("conversation-id"),
|
||||
"mute": !rt.Bool("off"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ConversationMuteAtAll toggles @all notification (update_at_all_notification_off, im).
|
||||
var ConversationMuteAtAll = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-mute-at-all",
|
||||
Product: "im",
|
||||
Description: "关闭/开启 @所有人消息提醒",
|
||||
Intent: "当你在某个群里不想再被'@所有人'打扰、或想恢复该提醒时使用;会实际修改该会话的@所有人提醒开关,需传 openConversationId。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
{Name: "off", Type: shortcut.FlagBool, Desc: "恢复接收 @所有人通知(不传则关闭通知)"},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-mute-at-all --conversation-id <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_at_all_notification_off", map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"cid": rt.Str("conversation-id"),
|
||||
"mute": !rt.Bool("off"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ConversationMuteRedEnvelope toggles red-envelope notification (update_red_env_notification_off, im).
|
||||
var ConversationMuteRedEnvelope = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-mute-red-envelope",
|
||||
Product: "im",
|
||||
Description: "关闭/开启红包消息提醒",
|
||||
Intent: "当你想在某个会话里关闭或恢复红包消息提醒时使用;会实际修改该会话的红包提醒开关,需传 openConversationId。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
{Name: "off", Type: shortcut.FlagBool, Desc: "恢复接收红包通知(不传则关闭通知)"},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-mute-red-envelope --conversation-id <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_red_env_notification_off", map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"cid": rt.Str("conversation-id"),
|
||||
"mute": !rt.Bool("off"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ConversationMarkUnread marks a conversation unread (mark_conversation_unread, im).
|
||||
var ConversationMarkUnread = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-mark-unread",
|
||||
Product: "im",
|
||||
Description: "标记会话为未读",
|
||||
Intent: "当你想把某个已读会话重新标记为未读(提醒自己稍后再处理)时使用;会实际改变该会话的未读状态,需传 openConversationId。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-mark-unread --conversation-id <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("mark_conversation_unread", map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"cid": rt.Str("conversation-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ConversationClearRedPoint clears a conversation red point (clear_conversation_red_point, im).
|
||||
var ConversationClearRedPoint = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-clear-red-point",
|
||||
Product: "im",
|
||||
Description: "清除会话红点",
|
||||
Intent: "当你想消除某个会话上的未读红点(小圆点)时使用;会实际清除该会话红点,需传 openConversationId。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-clear-red-point --conversation-id <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("clear_conversation_red_point", map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"cid": rt.Str("conversation-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ConversationClearAllRedPoint clears all red points (clear_all_red_point, im).
|
||||
var ConversationClearAllRedPoint = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-clear-all-red-point",
|
||||
Product: "im",
|
||||
Description: "清除所有会话红点(全部已读)",
|
||||
Intent: "当你想一键把全部会话标记为已读、清空所有红点时使用;会实际清除当前用户所有会话的红点,无需任何参数。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Tips: []string{`dws chat +conversation-clear-all-red-point`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("clear_all_red_point", map[string]any{})
|
||||
},
|
||||
}
|
||||
|
||||
// ConversationList paginates all conversations (list_all_conversations, im).
|
||||
var ConversationList = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-list",
|
||||
Product: "im",
|
||||
Description: "分页获取当前用户的全部会话列表(单聊+群聊)",
|
||||
Intent: "当你想遍历当前用户的所有会话(单聊+群聊)做统计、清理或批量处理时使用;只读分页返回,可用 --exclude-muted 排除已免打扰会话。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量(1-100)"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-list --limit 50`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Int("limit") > 0 {
|
||||
params["limit"] = rt.Int("limit")
|
||||
}
|
||||
if rt.Int("cursor") > 0 {
|
||||
params["cursor"] = rt.Int("cursor")
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_all_conversations", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
convs := conversationListProject(data)
|
||||
payload := map[string]any{"count": len(convs), "conversations": convs}
|
||||
// carry pagination hints when present so翻页仍可继续(字段防御式探测)。
|
||||
if v, ok := conversationListFirst(data, "nextCursor", "cursor"); ok {
|
||||
payload["nextCursor"] = v
|
||||
}
|
||||
if v, ok := conversationListFirst(data, "hasMore", "has_more"); ok {
|
||||
payload["hasMore"] = v
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
|
||||
// conversationListProject reshapes the raw list_all_conversations response into a
|
||||
// clean conversation list — clean output projection. Both the list
|
||||
// container and the 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 conversationListProject(data map[string]any) []map[string]any {
|
||||
raw := conversationListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := conversationListFirst(m, "openConversationId", "conversationId", "id"); ok {
|
||||
row["openConversationId"] = v
|
||||
}
|
||||
if v, ok := conversationListFirst(m, "conversationName", "name", "title"); ok {
|
||||
row["conversationName"] = v
|
||||
}
|
||||
if v, ok := conversationListFirst(m, "conversationType", "type"); ok {
|
||||
row["conversationType"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// conversationListResolveList locates the conversation array inside the response,
|
||||
// tolerating a bare top-level list or nesting one level under a common envelope.
|
||||
func conversationListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"conversationList", "conversations", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"conversationList", "conversations", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// conversationListFirst returns the first present candidate key's value.
|
||||
func conversationListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ConversationListTop lists pinned conversations (list_top_conversations, chat server).
|
||||
var ConversationListTop = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-list-top",
|
||||
Description: "拉取置顶会话列表",
|
||||
Intent: "当你只想查看被置顶的那些会话时使用;只读分页返回置顶会话列表,可用 --exclude-muted 排除已免打扰会话。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页数量"},
|
||||
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-list-top --limit 1000`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Int("limit") > 0 {
|
||||
params["limit"] = rt.Int("limit")
|
||||
}
|
||||
if rt.Int("cursor") > 0 {
|
||||
params["cursor"] = rt.Int("cursor")
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
}
|
||||
data, err := rt.CallMCPData("chat", "list_top_conversations", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
convs := conversationListTopProject(data)
|
||||
payload := map[string]any{"count": len(convs), "conversations": convs}
|
||||
if v, ok := conversationListTopFirst(data, "nextCursor", "cursor"); ok {
|
||||
payload["nextCursor"] = v
|
||||
}
|
||||
if v, ok := conversationListTopFirst(data, "hasMore", "has_more"); ok {
|
||||
payload["hasMore"] = v
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
|
||||
// conversationListTopProject reshapes the raw list_top_conversations response
|
||||
// into a clean pinned-conversation list — clean output projection.
|
||||
// Both the list container and the 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 conversationListTopProject(data map[string]any) []map[string]any {
|
||||
raw := conversationListTopResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := conversationListTopFirst(m, "openConversationId", "conversationId", "id"); ok {
|
||||
row["openConversationId"] = v
|
||||
}
|
||||
if v, ok := conversationListTopFirst(m, "conversationName", "name", "title"); ok {
|
||||
row["conversationName"] = v
|
||||
}
|
||||
if v, ok := conversationListTopFirst(m, "conversationType", "type"); ok {
|
||||
row["conversationType"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// conversationListTopResolveList locates the conversation array inside the
|
||||
// response, tolerating a bare top-level list or nesting one level under a
|
||||
// common envelope.
|
||||
func conversationListTopResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"conversationList", "conversations", "topConversations", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"conversationList", "conversations", "topConversations", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// conversationListTopFirst returns the first present candidate key's value.
|
||||
func conversationListTopFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ConversationClearMessages clears a conversation's chat records (clear_conversation_messages, im).
|
||||
var ConversationClearMessages = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-clear-messages",
|
||||
Product: "im",
|
||||
Description: "清空当前用户指定会话的聊天记录(仅本人视角,不可逆)",
|
||||
Intent: "当你要清空自己在某个会话里的聊天记录时使用;仅影响本人视角,但会实际删除且不可逆,需传 openConversationId,务必谨慎操作。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-clear-messages --conversation-id <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("clear_conversation_messages", map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"cid": rt.Str("conversation-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ConversationMarkRead marks a message read (mark_message_read, im).
|
||||
var ConversationMarkRead = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-mark-read",
|
||||
Product: "im",
|
||||
Description: "标记消息已读(该消息及之前的消息都标记为已读)",
|
||||
Intent: "当你想把某会话中某条消息及其之前的所有消息都标记为已读时使用;会实际更新已读位置,需传 openConversationId 和该消息 openMessageId。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-mark-read --conversation-id <openConversationId> --message-id <openMessageId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("mark_message_read", map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"openMessageId": rt.Str("message-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ConversationHide hides a conversation from the list (hide_conversation, im).
|
||||
var ConversationHide = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+conversation-hide",
|
||||
Product: "im",
|
||||
Description: "会话列表中隐藏会话(收到新消息会重新出现)",
|
||||
Intent: "当你想把某个会话从会话列表中暂时隐藏、让列表更清爽时使用;会实际隐藏该会话(收到新消息会自动重新出现),需传 openConversationId。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +conversation-hide --conversation-id <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("hide_conversation", map[string]any{
|
||||
"openConversationId": rt.Str("conversation-id"),
|
||||
"cid": rt.Str("conversation-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ── category: 会话分组管理 (im) ──────────────────────────────
|
||||
|
||||
// CategoryList lists user-defined conversation categories (list_user_define_conv_categories, im).
|
||||
var CategoryList = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+category-list",
|
||||
Product: "im",
|
||||
Description: "获取用户自定义会话分组",
|
||||
Intent: "当你想查看当前用户自建了哪些会话分组(如'工作群''项目群')时使用;只读返回分组列表及其 categoryId,供后续按分组拉会话或增删。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Tips: []string{`dws chat +category-list`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("im", "list_user_define_conv_categories", map[string]any{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
categories := categoryListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(categories), "categories": categories})
|
||||
},
|
||||
}
|
||||
|
||||
// categoryListProject reshapes the raw list_user_define_conv_categories response
|
||||
// into a clean {categoryId, title} list — clean output projection.
|
||||
// Both the list container and the 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 categoryListProject(data map[string]any) []map[string]any {
|
||||
raw := categoryListResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := categoryListFirst(m, "categoryId", "category_id", "id"); ok {
|
||||
row["categoryId"] = v
|
||||
}
|
||||
if v, ok := categoryListFirst(m, "title", "categoryName", "name"); ok {
|
||||
row["title"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// categoryListResolveList locates the category array inside the response,
|
||||
// tolerating a bare top-level list or nesting one level under a common envelope.
|
||||
func categoryListResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"categoryList", "categories", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"categoryList", "categories", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// categoryListFirst returns the first present candidate key's value.
|
||||
func categoryListFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// CategoryListConversations lists conversations in a category (list_conversations_by_category, im).
|
||||
var CategoryListConversations = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+category-list-conversations",
|
||||
Product: "im",
|
||||
Description: "拉取指定自定义会话分组下的会话",
|
||||
Intent: "当你想查看某个自定义会话分组里都归入了哪些会话时使用;只读,需传 categoryId,可用 --exclude-muted 排除已免打扰会话。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "category-id", Type: shortcut.FlagInt, Desc: "会话分组 ID", Required: true},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
|
||||
},
|
||||
Tips: []string{`dws chat +category-list-conversations --category-id <分组ID>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"categoryId": rt.Int("category-id")}
|
||||
if rt.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_conversations_by_category", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
convs := categoryConversationsProject(data)
|
||||
payload := map[string]any{"count": len(convs), "conversations": convs}
|
||||
if v, ok := categoryConversationsFirst(data, "nextCursor", "cursor"); ok {
|
||||
payload["nextCursor"] = v
|
||||
}
|
||||
if v, ok := categoryConversationsFirst(data, "hasMore", "has_more"); ok {
|
||||
payload["hasMore"] = v
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
|
||||
// categoryConversationsProject reshapes the raw list_conversations_by_category
|
||||
// response into a clean conversation list — clean output projection.
|
||||
// Both the list container and the 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 categoryConversationsProject(data map[string]any) []map[string]any {
|
||||
raw := categoryConversationsResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := categoryConversationsFirst(m, "openConversationId", "conversationId", "id"); ok {
|
||||
row["openConversationId"] = v
|
||||
}
|
||||
if v, ok := categoryConversationsFirst(m, "conversationName", "name", "title"); ok {
|
||||
row["conversationName"] = v
|
||||
}
|
||||
if v, ok := categoryConversationsFirst(m, "conversationType", "type"); ok {
|
||||
row["conversationType"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// categoryConversationsResolveList locates the conversation array inside the
|
||||
// response, tolerating a bare top-level list or nesting one level under a
|
||||
// common envelope.
|
||||
func categoryConversationsResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"conversationList", "conversations", "result", "data", "list", "items"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"conversationList", "conversations", "list", "items", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// categoryConversationsFirst returns the first present candidate key's value.
|
||||
func categoryConversationsFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// CategoryCreate creates a conversation category (create_conv_category, im).
|
||||
var CategoryCreate = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+category-create",
|
||||
Product: "im",
|
||||
Description: "创建用户自定义会话分组",
|
||||
Intent: "当你想新建一个会话分组来归类会话时使用;会实际创建分组并返回其 ID,需传分组名称 --title。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "分组名称", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +category-create --title "工作群"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("create_conv_category", map[string]any{"title": rt.Str("title")})
|
||||
},
|
||||
}
|
||||
|
||||
// CategoryDelete deletes a conversation category (delete_conv_category, im).
|
||||
var CategoryDelete = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+category-delete",
|
||||
Product: "im",
|
||||
Description: "删除用户自定义会话分组",
|
||||
Intent: "当你想删除某个自定义会话分组时使用;会实际删除分组(不影响其中会话本身),不可逆,需传 categoryId。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "category-id", Type: shortcut.FlagInt, Desc: "会话分组 ID", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +category-delete --category-id <分组ID>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("delete_conv_category", map[string]any{"categoryId": rt.Int("category-id")})
|
||||
},
|
||||
}
|
||||
|
||||
// CategoryRename renames a conversation category (rename_conv_category, im).
|
||||
var CategoryRename = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+category-rename",
|
||||
Product: "im",
|
||||
Description: "更新用户自定义会话分组的名称",
|
||||
Intent: "当你想重命名已有的自定义会话分组时使用;会实际更新分组名称,需传 categoryId 和新名称 --title。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "category-id", Type: shortcut.FlagInt, Desc: "会话分组 ID", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "新的分组名称", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +category-rename --category-id <分组ID> --title "新名称"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("rename_conv_category", map[string]any{
|
||||
"categoryId": rt.Int("category-id"),
|
||||
"title": rt.Str("title"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// CategoryAddConversation adds a conversation to categories (add_conv_to_categories, im).
|
||||
var CategoryAddConversation = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+category-add-conversation",
|
||||
Product: "im",
|
||||
Description: "将会话移动到指定的自定义分组中",
|
||||
Intent: "当你想把某个会话归入一个或多个自定义分组时使用;会实际把会话加入指定分组,需传会话 openConversationId 和目标分组 ID 列表。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
{Name: "category-ids", Type: shortcut.FlagStringSlice, Desc: "目标分组 ID 列表", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +category-add-conversation --group <openConversationId> --category-ids 123,456`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
ids, err := toInt64Slice(rt.StrSlice("category-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--category-ids: %w", err)
|
||||
}
|
||||
return rt.CallMCP("add_conv_to_categories", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"categoryIds": ids,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// CategoryRemoveConversation removes a conversation from categories (remove_conv_from_categories, im).
|
||||
var CategoryRemoveConversation = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+category-remove-conversation",
|
||||
Product: "im",
|
||||
Description: "将会话从指定的自定义分组中移出",
|
||||
Intent: "当你想把某个会话从指定自定义分组中移出时使用;会实际从分组移除该会话(不删除会话本身),需传会话 openConversationId 和分组 ID 列表。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
|
||||
{Name: "category-ids", Type: shortcut.FlagStringSlice, Desc: "目标分组 ID 列表", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +category-remove-conversation --group <openConversationId> --category-ids 123,456`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
ids, err := toInt64Slice(rt.StrSlice("category-ids"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--category-ids: %w", err)
|
||||
}
|
||||
return rt.CallMCP("remove_conv_from_categories", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"categoryIds": ids,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
ConversationInfo,
|
||||
ConversationSetTop,
|
||||
ConversationMute,
|
||||
ConversationMuteAtAll,
|
||||
ConversationMuteRedEnvelope,
|
||||
ConversationMarkUnread,
|
||||
ConversationClearRedPoint,
|
||||
ConversationClearAllRedPoint,
|
||||
ConversationList,
|
||||
ConversationListTop,
|
||||
ConversationClearMessages,
|
||||
ConversationMarkRead,
|
||||
ConversationHide,
|
||||
CategoryList,
|
||||
CategoryListConversations,
|
||||
CategoryCreate,
|
||||
CategoryDelete,
|
||||
CategoryRename,
|
||||
CategoryAddConversation,
|
||||
CategoryRemoveConversation,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
// 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 chat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// ChatSearch searches groups by keyword (search_groups on the im server).
|
||||
var ChatSearch = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-search",
|
||||
Product: "im",
|
||||
Description: "按关键词搜索群聊",
|
||||
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "群名称关键词", Required: true},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Default: "0", Desc: "分页游标,翻页传 nextCursor"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已设置免打扰的群聊"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-search --query "项目冲刺"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"keyword": rt.Str("query"),
|
||||
"limit": rt.Int("limit"),
|
||||
"cursor": rt.Str("cursor"),
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
}
|
||||
return rt.CallMCP("search_groups", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ChatMembersList lists members of a group (get_group_members, chat server).
|
||||
// ChatMembersGet batch-queries member detail by ids (list_group_member_by_ids, im).
|
||||
var ChatMembersGet = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-members-get",
|
||||
Product: "im",
|
||||
Description: "根据成员 openDingTalkId 批量查询群成员详情",
|
||||
Intent: "当你已有若干成员的 openDingTalkId、需要批量获取他们在该群内的详情(群昵称、角色等)时使用;只读,需传群 openConversationId 和成员 openDingTalkId 列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 openDingTalkId 列表", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-members-get --id <openConversationId> --users odid1,odid2`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("list_group_member_by_ids", map[string]any{
|
||||
"openConversationId": rt.Str("id"),
|
||||
"cid": rt.Str("id"),
|
||||
"memberOpenDingTalkIds": rt.StrSlice("users"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatMemberAdd adds members to a group (add_group_member, chat server).
|
||||
// ChatMemberRemove removes members from a group (remove_group_member, chat server).
|
||||
// ChatUpdateName renames a group (update_group_name, chat server).
|
||||
// ChatTransferOwner transfers group ownership (transfer_group_owner, im).
|
||||
var ChatTransferOwner = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-transfer-owner",
|
||||
Product: "im",
|
||||
Description: "转让群主",
|
||||
Intent: "当你要把群主身份转让给他人时使用;会实际变更群主(自己不再是群主),需传群 openConversationId 和新群主的 userId 或 openDingTalkId。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "new-owner", Type: shortcut.FlagString, Desc: "新群主 userId 或 openDingTalkId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-transfer-owner --group <openConversationId> --new-owner <openDingTalkId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
newOwner := rt.Str("new-owner")
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"cid": rt.Str("group"),
|
||||
}
|
||||
if isOpenID(newOwner) {
|
||||
params["newOwnerOpenDingTalkId"] = newOwner
|
||||
} else {
|
||||
params["newOwnerUid"] = newOwner
|
||||
}
|
||||
return rt.CallMCP("transfer_group_owner", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ChatInviteURL gets the group invite url (get_group_invite_url, im).
|
||||
var ChatInviteURL = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-invite-url",
|
||||
Product: "im",
|
||||
Description: "获取群邀请链接",
|
||||
Intent: "当你想拿到一条群邀请链接分享给别人加群时使用;只读生成链接,需传群 openConversationId,可用 --expires-seconds 设置有效期(0 表示永久)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "expires-seconds", Type: shortcut.FlagInt, Desc: "链接有效期(秒),0 表示永久"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-invite-url --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"cid": rt.Str("group"),
|
||||
}
|
||||
if rt.Changed("expires-seconds") {
|
||||
params["expiresSeconds"] = rt.Int("expires-seconds")
|
||||
}
|
||||
return rt.CallMCP("get_group_invite_url", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ChatQuit quits a group (quit_group, im).
|
||||
var ChatQuit = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-quit",
|
||||
Product: "im",
|
||||
Description: "退出群聊",
|
||||
Intent: "当你想让当前用户主动退出某个群时使用;会实际退群,需传群 openConversationId。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-quit --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("quit_group", map[string]any{"openConversationId": rt.Str("group")})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatUpdateIcon updates the group icon (update_group_icon, im).
|
||||
var ChatUpdateIcon = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-update-icon",
|
||||
Product: "im",
|
||||
Description: "更新群头像",
|
||||
Intent: "当你想更换群头像时使用;会实际更新群头像,需传群 openConversationId 和已上传头像的 mediaId(以 @ 开头)。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "icon-media-id", Type: shortcut.FlagString, Desc: "群头像 mediaId(以 @ 开头)", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-update-icon --group <openConversationId> --icon-media-id <mediaId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_group_icon", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"iconMediaId": rt.Str("icon-media-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatUpdateSettings updates a group setting (update_group_settings, im).
|
||||
var ChatUpdateSettings = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-update-settings",
|
||||
Product: "im",
|
||||
Description: "更新群设置(settingKey + status)",
|
||||
Intent: "当你想调整群的某项开关设置(如是否可被搜索 searchable、是否仅管理员可@所有人 onlyAdminCanAtAll)时使用;会实际修改群设置,需传 settingKey 和 status(0关/1开)。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "setting-key", Type: shortcut.FlagString, Desc: "群设置项 key,如 searchable / onlyAdminCanAtAll", Required: true},
|
||||
{Name: "status", Type: shortcut.FlagInt, Desc: "设置值:0=关闭,1=开启", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-update-settings --group <openConversationId> --setting-key searchable --status 1`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_group_settings", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"settingKey": rt.Str("setting-key"),
|
||||
"status": rt.Int("status"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatDismiss dismisses (destroys) a group (dismiss_group, im).
|
||||
var ChatDismiss = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-dismiss",
|
||||
Product: "im",
|
||||
Description: "解散群聊(不可逆,需群主权限)",
|
||||
Intent: "当你要彻底解散一个群时使用;会实际销毁群聊,不可逆且需群主权限,仅需传群 openConversationId,操作前务必确认。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-dismiss --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("dismiss_group", map[string]any{"openConversationId": rt.Str("group")})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatSetHistory sets new-member history visibility (update_show_history_msg_option, im).
|
||||
var ChatSetHistory = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-set-history",
|
||||
Product: "im",
|
||||
Description: "设置新成员入群可查看历史消息范围",
|
||||
Intent: "当你想控制新成员入群后能看到多少历史消息时使用;会实际修改群配置,需传群 openConversationId 和范围(FORBIDDEN 不可见 / RECENT_100 最近100条 / ALL 全部)。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "option", Type: shortcut.FlagString, Desc: "可见范围", Required: true, Enum: []string{"FORBIDDEN", "RECENT_100", "ALL"}},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-set-history --group <openConversationId> --option RECENT_100`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_show_history_msg_option", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"option": rt.Str("option"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatUpdateNick sets the caller's in-group nickname (update_group_nick, im).
|
||||
var ChatUpdateNick = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-update-nick",
|
||||
Product: "im",
|
||||
Description: "设置当前用户在群内的群昵称",
|
||||
Intent: "当你想设置当前用户在某个群里显示的群昵称时使用;会实际更新本人在该群的昵称,需传群 openConversationId 和昵称。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "nick", Type: shortcut.FlagString, Desc: "个人群昵称", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-update-nick --group <openConversationId> --nick "我的群昵称"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_group_nick", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"nick": rt.Str("nick"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatUpdateAlias sets the caller's private alias for a group (update_user_group_alias, im).
|
||||
var ChatUpdateAlias = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-update-alias",
|
||||
Product: "im",
|
||||
Description: "设置群备注(仅自己可见)",
|
||||
Intent: "当你想给某个群设置仅自己可见的备注名以便区分同名群时使用;会实际保存本人对该群的备注,需传群 openConversationId 和备注标题。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "alias-title", Type: shortcut.FlagString, Desc: "群备注标题", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-update-alias --group <openConversationId> --alias-title "项目A群"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_user_group_alias", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"aliasTitle": rt.Str("alias-title"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatListMine lists groups the caller owns/administers (list_owned_or_admin_groups, im).
|
||||
var ChatListMine = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-list-mine",
|
||||
Product: "im",
|
||||
Description: "拉取我创建/管理的群",
|
||||
Intent: "当你想查看自己作为群主或管理员在管理哪些群时使用;只读分页返回,可用 --role OWNER/ADMIN 按角色过滤。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "role", Type: shortcut.FlagString, Desc: "角色过滤", Enum: []string{"OWNER", "ADMIN"}},
|
||||
{Name: "limit", Type: shortcut.FlagInt, Desc: "最多返回群数量,不传返回全部"},
|
||||
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已设置免打扰的群聊"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-list-mine --role OWNER`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Changed("role") {
|
||||
params["roleFilter"] = rt.Str("role")
|
||||
}
|
||||
if rt.Int("limit") > 0 {
|
||||
params["limit"] = rt.Int("limit")
|
||||
}
|
||||
if rt.Bool("exclude-muted") {
|
||||
params["excludeMuted"] = true
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_owned_or_admin_groups", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := chatListMineProject(data)
|
||||
return rt.Output(map[string]any{"count": len(groups), "groups": groups})
|
||||
},
|
||||
}
|
||||
|
||||
// chatListMineProject reshapes list_owned_or_admin_groups into a clean group
|
||||
// list ({openConversationId, name, role, ownerUserId}) — output-projection
|
||||
// clean output projection. List container and per-item field names are probed
|
||||
// defensively across candidate keys so shape drift yields an empty list rather
|
||||
// than a crash or fabricated data.
|
||||
func chatListMineProject(data map[string]any) []map[string]any {
|
||||
raw := chatGroupResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := chatGroupFirst(m, "openConversationId", "openconversation_id", "conversationId", "id"); ok {
|
||||
row["openConversationId"] = v
|
||||
}
|
||||
if v, ok := chatGroupFirst(m, "name", "groupName", "title"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if v, ok := chatGroupFirst(m, "role", "roleType", "memberRole"); ok {
|
||||
row["role"] = v
|
||||
}
|
||||
if v, ok := chatGroupFirst(m, "ownerUserId", "ownerId", "owner"); ok {
|
||||
row["ownerUserId"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ChatListAll paginates all groups the caller joined (list_my_groups_pagination, im).
|
||||
var ChatListAll = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-list-all",
|
||||
Product: "im",
|
||||
Description: "分页拉取我加入的所有群列表",
|
||||
Intent: "当你想遍历当前用户加入的所有群做统计或批量操作时使用;只读分页返回全部已加入的群列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量(最大 200)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-list-all --limit 50`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Int("limit") > 0 {
|
||||
params["limit"] = rt.Int("limit")
|
||||
}
|
||||
if c := rt.Str("cursor"); c != "" && c != "0" {
|
||||
params["cursor"] = c
|
||||
}
|
||||
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groups := chatListAllProject(data)
|
||||
payload := map[string]any{"count": len(groups), "groups": groups}
|
||||
if v, ok := chatGroupFirst(data, "nextCursor", "next_cursor", "cursor"); ok {
|
||||
payload["nextCursor"] = v
|
||||
}
|
||||
if v, ok := chatGroupFirst(data, "hasMore", "has_more"); ok {
|
||||
payload["hasMore"] = v
|
||||
}
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
|
||||
// chatListAllProject reshapes list_my_groups_pagination into a clean group list
|
||||
// ({openConversationId, name}) — clean output projection. List
|
||||
// container and per-item field names are probed defensively across candidate
|
||||
// keys so shape drift yields an empty list rather than a crash or fabricated
|
||||
// data.
|
||||
func chatListAllProject(data map[string]any) []map[string]any {
|
||||
raw := chatGroupResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := chatGroupFirst(m, "openConversationId", "openconversation_id", "conversationId", "id"); ok {
|
||||
row["openConversationId"] = v
|
||||
}
|
||||
if v, ok := chatGroupFirst(m, "name", "groupName", "title"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chatGroupResolveList locates the list payload inside a group-list response,
|
||||
// tolerating a bare top-level array or nesting under common envelope keys.
|
||||
func chatGroupResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"result", "data", "list", "items", "groups", "conversations"} {
|
||||
v, ok := data[key]
|
||||
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", "groups", "conversations", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// chatGroupFirst returns the first present candidate key's value.
|
||||
func chatGroupFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ChatListJoinRequests paginates join-validation records (list_apply_join_group_records, im).
|
||||
var ChatListJoinRequests = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-list-join-requests",
|
||||
Product: "im",
|
||||
Description: "分页拉取入群验证记录",
|
||||
Intent: "当你作为群主/管理员想查看待处理的入群申请时使用;只读分页返回入群验证记录(含 recordId、申请人与邀请人 ID),供后续用 chat-audit-join 审批。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "单页数量(最大 50)"},
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-list-join-requests --limit 30`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Int("limit") > 0 {
|
||||
params["limit"] = rt.Int("limit")
|
||||
}
|
||||
if rt.Changed("cursor") {
|
||||
params["cursor"] = rt.Str("cursor")
|
||||
}
|
||||
return rt.CallMCP("list_apply_join_group_records", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ChatAuditJoin audits a join-validation record (audit_join_group, im).
|
||||
var ChatAuditJoin = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-audit-join",
|
||||
Product: "im",
|
||||
Description: "审批入群验证(通过/拒绝/删除/忽略/拉黑)",
|
||||
Intent: "当你要处理某条入群申请时使用;会实际执行通过/拒绝/删除/忽略/拉黑动作,需传群 openConversationId、recordId、申请人与邀请人 userId 及 status。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "record-id", Type: shortcut.FlagInt, Desc: "申请记录 ID", Required: true},
|
||||
{Name: "applicant", Type: shortcut.FlagString, Desc: "申请人 userId", Required: true},
|
||||
{Name: "inviter", Type: shortcut.FlagString, Desc: "邀请人 userId", Required: true},
|
||||
{Name: "status", Type: shortcut.FlagString, Desc: "审批动作", Required: true, Enum: []string{"AuditApprove", "AuditDelete", "AuditIgnore", "AuditRefuse", "AuditBlock"}},
|
||||
{Name: "description", Type: shortcut.FlagString, Desc: "审批说明"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-audit-join --group <openConversationId> --record-id 123 --applicant <userId> --inviter <userId> --status AuditApprove`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"applyRecordId": rt.Int("record-id"),
|
||||
"applicantUid": rt.Str("applicant"),
|
||||
"inviterUid": rt.Str("inviter"),
|
||||
"status": rt.Str("status"),
|
||||
}
|
||||
if rt.Changed("description") {
|
||||
params["auditDescription"] = rt.Str("description")
|
||||
}
|
||||
return rt.CallMCP("audit_join_group", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ChatGetByID looks up a group by numeric group id (get_conv_info_by_group_id, im).
|
||||
var ChatGetByID = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-get-by-id",
|
||||
Product: "im",
|
||||
Description: "根据群号获取群聊信息",
|
||||
Intent: "当你只知道群号(数字)、需要换取群 openConversationId 及群信息时使用;只读,需传 --group-id。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group-id", Type: shortcut.FlagInt, Desc: "群号(数字类型)", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-get-by-id --group-id 12345678`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("get_conv_info_by_group_id", map[string]any{"groupId": rt.Int("group-id")})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatAddBot adds a custom robot to a group (add_robot_to_group, bot).
|
||||
var ChatAddBot = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-add-bot",
|
||||
Product: "bot",
|
||||
Description: "将机器人添加到群中",
|
||||
Intent: "当你想把某个机器人添加进群(比如让日报机器人进群播报)时使用;会实际把机器人加入群聊,需传机器人 robotCode 和群 openConversationId。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "robot-code", Type: shortcut.FlagString, Desc: "机器人 Code", Required: true},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-add-bot --robot-code <robotCode> --id <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("add_robot_to_group", map[string]any{
|
||||
"robotCode": rt.Str("robot-code"),
|
||||
"openConversationId": rt.Str("id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatBots lists robots in a group (list_group_bots, bot).
|
||||
var ChatBots = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-bots",
|
||||
Product: "bot",
|
||||
Description: "查看群内所有机器人",
|
||||
Intent: "当你想查看某个群里已添加了哪些机器人时使用;需传群 openConversationId,只读返回群内机器人列表(含 openBotId,供后续移除)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-bots --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("bot", "list_group_bots", map[string]any{"openConversationId": rt.Str("group")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bots := chatBotsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(bots), "bots": bots})
|
||||
},
|
||||
}
|
||||
|
||||
// chatBotsProject reshapes list_group_bots into a clean bot list
|
||||
// ({openBotId, name}) — clean output projection. List container and
|
||||
// per-item field names are probed defensively across candidate keys so shape
|
||||
// drift yields an empty list rather than a crash or fabricated data.
|
||||
func chatBotsProject(data map[string]any) []map[string]any {
|
||||
raw := chatGroupResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := chatGroupFirst(m, "openBotId", "open_bot_id", "botId", "robotCode", "id"); ok {
|
||||
row["openBotId"] = v
|
||||
}
|
||||
if v, ok := chatGroupFirst(m, "name", "botName", "nick", "title"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ChatRemoveBot removes a robot from a group (remove_robot_in_group, bot).
|
||||
var ChatRemoveBot = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-remove-bot",
|
||||
Product: "bot",
|
||||
Description: "从群内移除机器人",
|
||||
Intent: "当你想把某个机器人从群里移除时使用;会实际移除机器人,不可逆,需传群 openConversationId 和机器人 openBotId。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "bot-id", Type: shortcut.FlagString, Desc: "机器人 openBotId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-remove-bot --id <openConversationId> --bot-id <openBotId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("remove_robot_in_group", map[string]any{
|
||||
"openConversationId": rt.Str("id"),
|
||||
"openBotId": rt.Str("bot-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatSetAdmin sets/unsets group admins (update_conv_member_roles, im).
|
||||
var ChatSetAdmin = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-set-admin",
|
||||
Product: "im",
|
||||
Description: "设置 / 取消群管理员",
|
||||
Intent: "当你想把某些成员设为或取消群管理员时使用;会实际变更成员角色,需传群 openConversationId 和成员 userId/openDingTalkId 列表,加 --off 取消管理员。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 userId 或 openDingTalkId 列表", Required: true},
|
||||
{Name: "off", Type: shortcut.FlagBool, Desc: "取消管理员(不传则设为管理员)"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-set-admin --group <openConversationId> --users userId1,userId2`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
userIDs, openIDs := splitIDs(rt.StrSlice("users"))
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"admin": !rt.Bool("off"),
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
params["uids"] = userIDs
|
||||
}
|
||||
if len(openIDs) > 0 {
|
||||
params["openDingTalkIds"] = openIDs
|
||||
}
|
||||
return rt.CallMCP("update_conv_member_roles", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ChatMute mutes/unmutes the whole group (set_group_mute, im).
|
||||
var ChatMute = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-mute",
|
||||
Product: "im",
|
||||
Description: "全员禁言 / 取消全员禁言",
|
||||
Intent: "当你想对整个群开启或取消全员禁言时使用;会实际切换群的全员禁言状态,需传群 openConversationId,加 --off 取消禁言。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "off", Type: shortcut.FlagBool, Desc: "取消全员禁言(不传则开启禁言)"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-mute --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("set_group_mute", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"mute": !rt.Bool("off"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatMuteMember mutes/unmutes specific members (set_group_member_mute_list, im).
|
||||
var ChatMuteMember = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-mute-member",
|
||||
Product: "im",
|
||||
Description: "指定群成员禁言 / 取消禁言",
|
||||
Intent: "当你想只禁言或解禁群里的指定成员时使用;会实际把成员加入或移出禁言名单,需传群 openConversationId 和成员列表,禁言时还需 --mute-time(毫秒)。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "users", Type: shortcut.FlagStringSlice, Desc: "成员 userId 或 openDingTalkId 列表", Required: true},
|
||||
{Name: "mute-time", Type: shortcut.FlagInt, Desc: "禁言时长(毫秒),如 300000/3600000/86400000/604800000/2592000000"},
|
||||
{Name: "off", Type: shortcut.FlagBool, Desc: "移出禁言名单(不传则加入禁言名单)"},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-mute-member --group <openConversationId> --users userId1 --mute-time 3600000`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
userIDs, openIDs := splitIDs(rt.StrSlice("users"))
|
||||
off := rt.Bool("off")
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"cid": rt.Str("group"),
|
||||
"mute": !off,
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
params["uids"] = userIDs
|
||||
}
|
||||
if len(openIDs) > 0 {
|
||||
params["openDingTalkIds"] = openIDs
|
||||
}
|
||||
if !off {
|
||||
if rt.Int("mute-time") <= 0 {
|
||||
return fmt.Errorf("--mute-time 为禁言时必填(毫秒)")
|
||||
}
|
||||
params["muteTime"] = rt.Int("mute-time")
|
||||
}
|
||||
return rt.CallMCP("set_group_member_mute_list", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ── group-role: 群身份管理 (im) ──────────────────────────────
|
||||
|
||||
// ChatRoleList lists custom group roles (list_custom_group_roles, im).
|
||||
var ChatRoleList = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-role-list",
|
||||
Product: "im",
|
||||
Description: "拉取会话的群身份列表",
|
||||
Intent: "当你想查看某群自定义的群身份(如'班长''值日')都有哪些时使用;需传群 openConversationId,只读返回群身份列表及 openRoleId。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-role-list --group <openConversationId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("im", "list_custom_group_roles", map[string]any{"openConversationId": rt.Str("group")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roles := chatRoleListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(roles), "roles": roles})
|
||||
},
|
||||
}
|
||||
|
||||
// chatRoleListProject reshapes list_custom_group_roles into a clean group-role
|
||||
// list ({openRoleId, name}) — clean output projection. List
|
||||
// container and per-item field names are probed defensively across candidate
|
||||
// keys so shape drift yields an empty list rather than a crash or fabricated
|
||||
// data.
|
||||
func chatRoleListProject(data map[string]any) []map[string]any {
|
||||
raw := chatGroupResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := chatGroupFirst(m, "openRoleId", "open_role_id", "roleId", "id"); ok {
|
||||
row["openRoleId"] = v
|
||||
}
|
||||
if v, ok := chatGroupFirst(m, "name", "roleName", "title"); ok {
|
||||
row["name"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ChatRoleAdd adds a custom group role (add_custom_group_role, im).
|
||||
var ChatRoleAdd = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-role-add",
|
||||
Product: "im",
|
||||
Description: "添加群身份",
|
||||
Intent: "当你想在群里新增一个自定义群身份/头衔时使用;会实际创建群身份,需传群 openConversationId 和身份名称。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "群身份名称", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-role-add --group <openConversationId> --name "管理员"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("add_custom_group_role", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"name": rt.Str("name"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatRoleUpdate renames a custom group role (update_custom_group_role, im).
|
||||
var ChatRoleUpdate = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-role-update",
|
||||
Product: "im",
|
||||
Description: "更新群身份名称",
|
||||
Intent: "当你想重命名已有的群身份时使用;会实际更新身份名称,需传群 openConversationId、身份 openRoleId 和新名称。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "role-id", Type: shortcut.FlagString, Desc: "群身份 openRoleId", Required: true},
|
||||
{Name: "name", Type: shortcut.FlagString, Desc: "群身份新名称", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-role-update --group <openConversationId> --role-id <openRoleId> --name "新名称"`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_custom_group_role", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"openRoleId": rt.Str("role-id"),
|
||||
"name": rt.Str("name"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatRoleRemove deletes a custom group role (remove_custom_group_role, im).
|
||||
var ChatRoleRemove = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-role-remove",
|
||||
Product: "im",
|
||||
Description: "删除群身份",
|
||||
Intent: "当你想删除某个自定义群身份时使用;会实际删除群身份,不可逆,需传群 openConversationId 和身份 openRoleId。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "role-id", Type: shortcut.FlagString, Desc: "群身份 openRoleId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-role-remove --group <openConversationId> --role-id <openRoleId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("remove_custom_group_role", map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"openRoleId": rt.Str("role-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// ChatRoleSetUser overwrites a user's group roles (set_custom_user_roles, im).
|
||||
var ChatRoleSetUser = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-role-set-user",
|
||||
Product: "im",
|
||||
Description: "设置用户的群身份(覆盖该用户的全部群身份)",
|
||||
Intent: "当你想为某成员整体设定其在群内的身份时使用;会实际改写该用户的群身份集合(覆盖其原有全部身份),需传群、用户和 openRoleId 列表(传空则清除全部)。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "user", Type: shortcut.FlagString, Desc: "用户 userId 或 openDingTalkId", Required: true},
|
||||
{Name: "role-ids", Type: shortcut.FlagStringSlice, Desc: "群身份 openRoleId 列表(空则清除全部)", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-role-set-user --group <openConversationId> --user <userId> --role-ids roleId1,roleId2`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
user := rt.Str("user")
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"openRoleIds": rt.StrSlice("role-ids"),
|
||||
}
|
||||
if isOpenID(user) {
|
||||
params["openDingTalkId"] = user
|
||||
} else {
|
||||
params["userId"] = user
|
||||
}
|
||||
return rt.CallMCP("set_custom_user_roles", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ChatRoleRemoveUser removes specific roles from a user (remove_custom_user_roles, im).
|
||||
var ChatRoleRemoveUser = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-role-remove-user",
|
||||
Product: "im",
|
||||
Description: "移除用户的指定群身份",
|
||||
Intent: "当你只想撤销某成员的部分群身份、保留其余时使用;会实际移除指定的群身份,需传群、用户和要移除的 openRoleId 列表。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "user", Type: shortcut.FlagString, Desc: "用户 userId 或 openDingTalkId", Required: true},
|
||||
{Name: "role-ids", Type: shortcut.FlagStringSlice, Desc: "要移除的群身份 openRoleId 列表", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-role-remove-user --group <openConversationId> --user <userId> --role-ids roleId1`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
user := rt.Str("user")
|
||||
params := map[string]any{
|
||||
"openConversationId": rt.Str("group"),
|
||||
"openRoleIds": rt.StrSlice("role-ids"),
|
||||
}
|
||||
if isOpenID(user) {
|
||||
params["openDingTalkId"] = user
|
||||
} else {
|
||||
params["userId"] = user
|
||||
}
|
||||
return rt.CallMCP("remove_custom_user_roles", params)
|
||||
},
|
||||
}
|
||||
|
||||
// ChatRoleQueryUser queries a member's group roles (query_custom_user_roles, im).
|
||||
var ChatRoleQueryUser = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+chat-role-query-user",
|
||||
Product: "im",
|
||||
Description: "查询群成员的群身份",
|
||||
Intent: "当你想查看某个群成员当前拥有哪些群身份时使用;只读,需传群 openConversationId 和用户 userId 或 openDingTalkId。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "group", Type: shortcut.FlagString, Desc: "群 openConversationId", Required: true},
|
||||
{Name: "user", Type: shortcut.FlagString, Desc: "用户 userId 或 openDingTalkId", Required: true},
|
||||
},
|
||||
Tips: []string{`dws chat +chat-role-query-user --group <openConversationId> --user <userId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
user := rt.Str("user")
|
||||
params := map[string]any{"openConversationId": rt.Str("group")}
|
||||
if isOpenID(user) {
|
||||
params["openDingTalkId"] = user
|
||||
} else {
|
||||
params["userId"] = user
|
||||
}
|
||||
return rt.CallMCP("query_custom_user_roles", params)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
ChatSearch,
|
||||
ChatMembersGet,
|
||||
ChatTransferOwner,
|
||||
ChatInviteURL,
|
||||
ChatQuit,
|
||||
ChatUpdateIcon,
|
||||
ChatUpdateSettings,
|
||||
ChatDismiss,
|
||||
ChatSetHistory,
|
||||
ChatUpdateNick,
|
||||
ChatUpdateAlias,
|
||||
ChatListMine,
|
||||
ChatListAll,
|
||||
ChatListJoinRequests,
|
||||
ChatAuditJoin,
|
||||
ChatGetByID,
|
||||
ChatAddBot,
|
||||
ChatBots,
|
||||
ChatRemoveBot,
|
||||
ChatSetAdmin,
|
||||
ChatMute,
|
||||
ChatMuteMember,
|
||||
ChatRoleList,
|
||||
ChatRoleAdd,
|
||||
ChatRoleUpdate,
|
||||
ChatRoleRemove,
|
||||
ChatRoleSetUser,
|
||||
ChatRoleRemoveUser,
|
||||
ChatRoleQueryUser,
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,502 @@
|
||||
// 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 contact provides declarative shortcuts for the DingTalk contact
|
||||
// (通讯录) service: user / department / role / relation queries and the HR
|
||||
// roster (花名册) lookups. Each shortcut maps 1:1 onto an MCP tool declared in
|
||||
// internal/helpers/contact.go.
|
||||
package contact
|
||||
|
||||
import "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
|
||||
// GetSelf 获取当前登录用户信息(我是谁 / 本人)。
|
||||
// ListFollowings 获取当前用户的特别关注列表。
|
||||
var ListFollowings = shortcut.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+list-followings",
|
||||
Product: "contact",
|
||||
Description: "获取当前用户的特别关注列表",
|
||||
Intent: "当你想查看本人在通讯录里「特别关注」的联系人名单(例如常打交道的同事、上级)时使用;无需输入,返回关注对象的用户列表,可用于快速定位这些人的 userId 再发消息或排日程。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Tips: []string{
|
||||
`dws contact +list-followings`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Project the raw {arguments,result:{models:[…]}} envelope down to a
|
||||
// clean {count, followings:[{openDingTalkId}]} list.
|
||||
data, err := rt.CallMCPData("contact", "list_my_followings", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
followings := listFollowingsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(followings), "followings": followings})
|
||||
},
|
||||
}
|
||||
|
||||
// listFollowingsProject reshapes a list_my_followings response into a clean
|
||||
// list, unwrapping the result.models container the gateway uses.
|
||||
func listFollowingsProject(data map[string]any) []map[string]any {
|
||||
var raw []any
|
||||
if res, ok := data["result"].(map[string]any); ok {
|
||||
raw, _ = res["models"].([]any)
|
||||
}
|
||||
if raw == nil {
|
||||
for _, k := range []string{"models", "list", "items"} {
|
||||
if arr, ok := data[k].([]any); ok {
|
||||
raw = arr
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, it := range raw {
|
||||
m, ok := it.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
for _, k := range []string{"openDingTalkId", "openDingtalkId", "userId", "name"} {
|
||||
if v, ok := m[k]; ok && v != nil {
|
||||
row[k] = v
|
||||
}
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SearchUser 按关键词搜索通讯录用户。
|
||||
var SearchUser = shortcut.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+search-user",
|
||||
Product: "contact",
|
||||
Description: "按关键词搜索通讯录用户",
|
||||
Intent: "当你只知道某人的姓名、花名或部分名字,需要把它解析成 userId 及部门等信息以便后续发消息、排日程或指派任务时使用;输入搜索关键词(--query),返回匹配的用户列表。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +search-user --query "张三"`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
|
||||
"keyword": rt.Str("query"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := searchUserProject(data)
|
||||
return rt.Output(map[string]any{"count": len(users), "users": users})
|
||||
},
|
||||
}
|
||||
|
||||
// searchUserProject reshapes the raw search_contact_by_key_word response into a
|
||||
// clean, stable user list (name/userId/flowerName/openDingTalkId/title) — the
|
||||
// the clean output projection applied to every list command.
|
||||
// Field names are probed defensively across candidate keys.
|
||||
func searchUserProject(data map[string]any) []map[string]any {
|
||||
raw, ok := data["result"].([]any)
|
||||
if !ok {
|
||||
return []map[string]any{}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
for _, k := range []string{"name", "userId", "flowerName", "openDingTalkId", "title"} {
|
||||
if v, ok := m[k]; ok {
|
||||
row[k] = v
|
||||
}
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SearchMobile 按手机号搜索通讯录用户。
|
||||
var SearchMobile = shortcut.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+search-mobile",
|
||||
Product: "contact",
|
||||
Description: "按手机号搜索通讯录用户",
|
||||
Intent: "当你手里只有某人的手机号、需要反查出对应的通讯录用户和 userId 时使用;输入手机号(--mobile),返回该手机号所属的用户信息,适合从电话或名片信息定位到具体员工。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "mobile", Type: shortcut.FlagString, Desc: "手机号", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +search-mobile --mobile 13800138000`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("contact", "search_user_by_mobile", map[string]any{
|
||||
"mobile": rt.Str("mobile"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := searchUserProject(data)
|
||||
return rt.Output(map[string]any{"count": len(users), "users": users})
|
||||
},
|
||||
}
|
||||
|
||||
// GetUser 批量获取用户详情(组织管理信息:部门、主管、管理员权限)。
|
||||
// ListRoles 获取企业所有角色(标签)列表。
|
||||
var ListRoles = shortcut.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+list-roles",
|
||||
Product: "contact",
|
||||
Description: "获取企业所有角色(标签)列表",
|
||||
Intent: "当你想总览企业里都有哪些角色/员工标签(如「管理员」「财务」「销售」)及其角色 ID 时使用;无需输入,返回全量角色列表,常用于按角色圈定人群前先摸清有哪些角色可选。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Tips: []string{
|
||||
`dws contact +list-roles`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("contact", "get_org_labels", map[string]any{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roles := listRolesProject(data)
|
||||
return rt.Output(map[string]any{"count": len(roles), "roles": roles})
|
||||
},
|
||||
}
|
||||
|
||||
// listRolesProject reshapes the raw get_org_labels response into a clean
|
||||
// role/label list ({labelId, labelName}) — clean output projection.
|
||||
// The list container and field names are probed defensively across candidate
|
||||
// keys so the projection tolerates response-shape drift.
|
||||
func listRolesProject(data map[string]any) []map[string]any {
|
||||
raw := listRolesResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := listRolesFirst(m, "labelId", "label_id", "id"); ok {
|
||||
row["labelId"] = v
|
||||
}
|
||||
if v, ok := listRolesFirst(m, "labelName", "label_name", "name"); ok {
|
||||
row["labelName"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listRolesResolveList locates the list payload inside the response, tolerating
|
||||
// a bare top-level array or nesting under result/data/list/items containers.
|
||||
func listRolesResolveList(data map[string]any) []any {
|
||||
for _, key := range []string{"result", "data", "list", "items", "labels"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
// container may itself wrap the list one level deeper
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"list", "items", "labels", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// listRolesFirst returns the first present candidate key's value.
|
||||
func listRolesFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// SearchRole 根据角色名称精确匹配查询角色(角色ID、名称)。
|
||||
// ListRoleMembers 根据角色 ID 查询该角色下的成员列表。
|
||||
var ListRoleMembers = shortcut.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+list-role-members",
|
||||
Product: "contact",
|
||||
Description: "查询角色下的成员列表",
|
||||
Intent: "当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表,通常先用 +search-role 拿到角色 ID 再调用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "角色 ID", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +list-role-members --id 12345`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("contact", "get_label_members_by_labelId", map[string]any{
|
||||
"labelId": rt.Str("id"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members := memberListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(members), "members": members})
|
||||
},
|
||||
}
|
||||
|
||||
// memberListProject reshapes a user/member list response into a clean
|
||||
// {userId, name} list — clean output projection. Both the list
|
||||
// container and the per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data. Shared by role-member and dept-member listings.
|
||||
func memberListProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
raw := memberListFindList(data)
|
||||
if raw == nil {
|
||||
for _, container := range []string{"result", "data"} {
|
||||
if inner, ok := data[container].(map[string]any); ok {
|
||||
if r := memberListFindList(inner); r != nil {
|
||||
raw = r
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v := memberListFirst(m, "userId", "user_id", "userid", "id"); v != nil {
|
||||
row["userId"] = v
|
||||
}
|
||||
if v := memberListFirst(m, "name", "userName", "user_name", "flowerName"); v != nil {
|
||||
row["name"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// memberListFindList returns the first slice found under the common list
|
||||
// container keys, or nil when none is present.
|
||||
func memberListFindList(m map[string]any) []any {
|
||||
for _, k := range []string{"result", "data", "list", "items", "members", "userList"} {
|
||||
if arr, ok := m[k].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// memberListFirst returns the value of the first present key among keys.
|
||||
func memberListFirst(m map[string]any, keys ...string) any {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchDept 按关键词搜索部门。
|
||||
// ListSubDepts 查看指定部门的子部门。
|
||||
var ListSubDepts = shortcut.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+list-sub-depts",
|
||||
Product: "contact",
|
||||
Description: "查看指定部门的子部门",
|
||||
Intent: "当你想逐层浏览组织架构、查看某个部门下一级的子部门时使用;输入父部门 ID(--dept,根部门为 1),返回其直属子部门列表,可用于自顶向下遍历部门树。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "dept", Type: shortcut.FlagInt, Desc: "部门 ID(钉钉根部门为 1)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +list-sub-depts --dept 1`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("contact", "get_sub_depts_by_dept_id", map[string]any{
|
||||
"deptId": rt.Int("dept"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
depts := listSubDeptsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(depts), "depts": depts})
|
||||
},
|
||||
}
|
||||
|
||||
// listSubDeptsProject reshapes get_sub_depts_by_dept_id into a clean
|
||||
// {deptId, deptName} list — clean output projection. Both the list
|
||||
// container and the per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func listSubDeptsProject(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return []map[string]any{}
|
||||
}
|
||||
// Locate the list container: it may sit at the top level or be nested one
|
||||
// level under a common envelope key.
|
||||
raw := listSubDeptsFindList(data)
|
||||
if raw == nil {
|
||||
for _, container := range []string{"result", "data"} {
|
||||
if inner, ok := data[container].(map[string]any); ok {
|
||||
if r := listSubDeptsFindList(inner); r != nil {
|
||||
raw = r
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v := listSubDeptsFirst(m, "deptId", "dept_id", "id"); v != nil {
|
||||
row["deptId"] = v
|
||||
}
|
||||
if v := listSubDeptsFirst(m, "deptName", "dept_name", "name"); v != nil {
|
||||
row["deptName"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listSubDeptsFindList returns the first slice found under the common list
|
||||
// container keys, or nil when none is present.
|
||||
func listSubDeptsFindList(m map[string]any) []any {
|
||||
for _, k := range []string{"result", "data", "list", "items"} {
|
||||
if arr, ok := m[k].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listSubDeptsFirst returns the value of the first present key among keys.
|
||||
func listSubDeptsFirst(m map[string]any, keys ...string) any {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDept 获取部门详情(部门 ID、名称、人数)。
|
||||
// ListDeptMembers 查看部门成员(仅本部门,不含下级)。
|
||||
var ListDeptMembers = shortcut.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+list-dept-members",
|
||||
Product: "contact",
|
||||
Description: "查看部门成员(仅本部门,不含下级)",
|
||||
Intent: "当你想列出一个或多个部门本级的员工(不含下级子部门)以便群发通知、统计或指派任务时使用;输入部门 ID 列表(--depts,逗号分隔),返回这些部门下的成员,如需含下级需自行遍历子部门。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "depts", Type: shortcut.FlagStringSlice, Desc: "部门 ID 列表,逗号分隔", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +list-dept-members --depts 12345,67890`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("contact", "get_dept_members_by_deptId", map[string]any{
|
||||
"deptIds": rt.StrSlice("depts"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members := memberListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(members), "members": members})
|
||||
},
|
||||
}
|
||||
|
||||
// ListRosterFields 查询花名册当前用户有权限的字段列表(hrmregister server)。
|
||||
var ListRosterFields = shortcut.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+list-roster-fields",
|
||||
Product: "hrmregister",
|
||||
Description: "查询花名册有权限的字段列表",
|
||||
Intent: "当你要查询花名册(HR 档案)信息、需要先知道当前身份有权访问哪些字段及其字段编码(fieldCode)时使用;无需输入,返回可用字段列表,通常作为调用 +get-roster 前的准备步骤以指定 --fields。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Tips: []string{
|
||||
`dws contact +list-roster-fields`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("list_authorized_roster_fields", map[string]any{})
|
||||
},
|
||||
}
|
||||
|
||||
// GetRoster 查询员工花名册字段信息(个人档案,hrmregister server)。
|
||||
var GetRoster = shortcut.Shortcut{
|
||||
Service: "contact",
|
||||
Command: "+get-roster",
|
||||
Product: "hrmregister",
|
||||
Description: "查询员工花名册字段信息(学历、家庭、银行卡、合同等)",
|
||||
Intent: "当你需要查看某员工在 HR 花名册中的详细档案字段(如学历、家庭、银行卡、合同等)时使用;可传员工 ID(--staff-id)和要查的字段编码(--fields,来自 +list-roster-fields),不传则按默认查询,返回授权范围内的花名册信息。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "staff-id", Type: shortcut.FlagString, Desc: "查询员工 ID(可选)"},
|
||||
{Name: "fields", Type: shortcut.FlagStringSlice, Desc: "指定字段集合,逗号分隔,可通过 +list-roster-fields 获取(可选)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +get-roster --staff-id STAFF_ID`,
|
||||
`dws contact +get-roster --staff-id STAFF_ID --fields fieldCode1,fieldCode2`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{}
|
||||
if rt.Changed("staff-id") {
|
||||
params["staffId"] = rt.Str("staff-id")
|
||||
}
|
||||
if rt.Changed("fields") {
|
||||
params["fieldCodeList"] = rt.StrSlice("fields")
|
||||
}
|
||||
return rt.CallMCP("get_authorized_emp_rosterInfo", params)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
ListFollowings,
|
||||
SearchUser,
|
||||
SearchMobile,
|
||||
ListRoles,
|
||||
ListRoleMembers,
|
||||
ListSubDepts,
|
||||
ListDeptMembers,
|
||||
ListRosterFields,
|
||||
GetRoster,
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user