Compare commits
133
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
262248d08d | ||
|
|
e7955b5891 | ||
|
|
efb172dcd6 | ||
|
|
0cbb1b8d30 | ||
|
|
db6addfc0e | ||
|
|
17f153a070 | ||
|
|
959bc4c1a8 | ||
|
|
9f80006b3b | ||
|
|
e530aea14d | ||
|
|
32515729af | ||
|
|
ef71673c69 | ||
|
|
af0086c9a8 | ||
|
|
28f75dec0a | ||
|
|
15d5be6000 | ||
|
|
ebfba82ebc | ||
|
|
ec9897678f | ||
|
|
af6e566abd | ||
|
|
4ea298ec61 | ||
|
|
7aba24b690 | ||
|
|
60e278f32f | ||
|
|
7acbec2615 | ||
|
|
6811a12330 | ||
|
|
c54a9e1e5e | ||
|
|
a15fb22671 | ||
|
|
a1712337a8 | ||
|
|
d65ad9aa2e | ||
|
|
7688f95d2b | ||
|
|
c2ee691db7 | ||
|
|
368c879f45 | ||
|
|
1920552ab0 | ||
|
|
f1b5330a4e | ||
|
|
f9ccff963f | ||
|
|
5cbc11d58f | ||
|
|
49a17b4375 | ||
|
|
a0a995cfee | ||
|
|
354c4546d8 | ||
|
|
3ab22071fb | ||
|
|
a34f46794e | ||
|
|
f0b0bdbe48 | ||
|
|
1fe019994d | ||
|
|
85cb41423b | ||
|
|
b9e7ff8c55 | ||
|
|
02ccd9d134 | ||
|
|
0353215b1d | ||
|
|
b94e21331d | ||
|
|
637a6f2f68 | ||
|
|
ae9ba333a0 | ||
|
|
6dbc8399df | ||
|
|
24cdb85d71 | ||
|
|
a1f8ecb7f0 | ||
|
|
a34ca5a137 | ||
|
|
7887b9473f | ||
|
|
fd3c82aa91 | ||
|
|
9266632694 | ||
|
|
b0cbcd7a04 | ||
|
|
6251117bd0 | ||
|
|
566803c431 | ||
|
|
d6848da60b | ||
|
|
53169a41af | ||
|
|
e90d5bac68 | ||
|
|
d725bdbaa3 | ||
|
|
d75b744a35 | ||
|
|
9aea8c0b5c | ||
|
|
7134f33e1d | ||
|
|
f54b964d62 | ||
|
|
11fbeb4851 | ||
|
|
2b1f38edae | ||
|
|
37fbb110e3 | ||
|
|
7564496ea0 | ||
|
|
a0b0d98180 | ||
|
|
b742343937 | ||
|
|
657df05d40 | ||
|
|
86f2b14449 | ||
|
|
86014c97cf | ||
|
|
94f3bba504 | ||
|
|
339eaa4b1b | ||
|
|
96774e6e23 | ||
|
|
665b79d8da | ||
|
|
32e7a80889 | ||
|
|
5e6b588b5e | ||
|
|
c1d90672a8 | ||
|
|
3d2d287723 | ||
|
|
969292a8d7 | ||
|
|
ac610f2d24 | ||
|
|
98f45cfe23 | ||
|
|
fbf97ce402 | ||
|
|
f5b029b1a5 | ||
|
|
75f92cf546 | ||
|
|
6d485f47eb | ||
|
|
6651a162c5 | ||
|
|
a9aa39c3e2 | ||
|
|
b0cd419f28 | ||
|
|
aa487002b7 | ||
|
|
941bf01e30 | ||
|
|
b439c5fa09 | ||
|
|
67250a9da5 | ||
|
|
70243acb95 | ||
|
|
6cce7fdbd8 | ||
|
|
fae21ec9f2 | ||
|
|
779fd82a88 | ||
|
|
3e60b83881 | ||
|
|
66a676b6aa | ||
|
|
23bbbccb7e | ||
|
|
fd61868393 | ||
|
|
2ea5acc2a3 | ||
|
|
5e1bac51e5 | ||
|
|
f1e95d763d | ||
|
|
9140015c42 | ||
|
|
de418c5696 | ||
|
|
5ec5b9811c | ||
|
|
378eaa377e | ||
|
|
d4368be1c3 | ||
|
|
9733acfb5a | ||
|
|
b1d422dcfd | ||
|
|
1231e500a3 | ||
|
|
c508968814 | ||
|
|
65ce71b8d4 | ||
|
|
6bfcac4d54 | ||
|
|
f819566c63 | ||
|
|
10d9aa3058 | ||
|
|
a0ca1fdb28 | ||
|
|
10943629d6 | ||
|
|
5a93f80daa | ||
|
|
8260cf7f53 | ||
|
|
9fd38d9b9d | ||
|
|
58dfbc5b6e | ||
|
|
9fd9ae7a95 | ||
|
|
5b0198b2ec | ||
|
|
dc854acb9b | ||
|
|
d054e3dc01 | ||
|
|
95536c3e97 | ||
|
|
1bfedbd4d2 | ||
|
|
cfaf161fc7 |
@@ -111,6 +111,7 @@ jobs:
|
||||
filename.startsWith('internal/app/') ||
|
||||
filename.startsWith('internal/cli/') ||
|
||||
filename.startsWith('internal/cobracmd/') ||
|
||||
filename.startsWith('internal/corecmd/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/i18n/') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
@@ -152,7 +153,6 @@ jobs:
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
filename.startsWith('internal/app/upgrade') ||
|
||||
filename.startsWith('internal/transport/') ||
|
||||
filename.startsWith('internal/recovery/') ||
|
||||
filename.startsWith('internal/syncdata/') ||
|
||||
filename.includes('/testdata/') ||
|
||||
filename.startsWith('testdata/') ||
|
||||
@@ -1176,7 +1176,7 @@ jobs:
|
||||
FULL_SUITE: ${{ needs.lint.outputs.full_suite }}
|
||||
COVERAGE_TARGET: "100"
|
||||
COVERAGE_ENFORCE_OVERALL: "false"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0"
|
||||
COVERAGE_OVERALL_TOLERANCE: "0.1"
|
||||
run: |
|
||||
policy_profile=coverage-policy.txt
|
||||
if [ "$FULL_SUITE" != true ]; then
|
||||
@@ -1386,6 +1386,13 @@ jobs:
|
||||
BASE_REF="$COMPATIBILITY_STABLE_REF"
|
||||
fi
|
||||
|
||||
- name: Check complete CLI command compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: |
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
--base-ref "$COMPATIBILITY_BASE_REF" \
|
||||
--stable-ref "$COMPATIBILITY_STABLE_REF"
|
||||
|
||||
- name: Check complete Schema compatibility
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && (needs.lint.outputs.full_suite == 'true' || needs.lint.outputs.interface_sensitive == 'true') }}
|
||||
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
|
||||
|
||||
@@ -302,9 +302,8 @@ on the leaf:
|
||||
|
||||
```bash
|
||||
dws auth status # token_valid should be true
|
||||
dws cache refresh # deprecated no-op: prints a retirement notice (discovery cache is gone; refreshes nothing)
|
||||
dws schema <mcp-canonical> -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" -f json
|
||||
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||
```
|
||||
|
||||
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
|
||||
@@ -321,8 +320,10 @@ Skill (evidence only)**.
|
||||
|
||||
Split work by product groups. Each agent must:
|
||||
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
|
||||
for its tools.
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
|
||||
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
|
||||
audits may query the full leaf only through a narrow `--jq` / `--fields`
|
||||
projection; do not load an entire full leaf into Agent context.
|
||||
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
|
||||
forbid wholesale JSON merges from review dumps.
|
||||
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
|
||||
@@ -471,13 +472,15 @@ path; a generator unit test or JSON count alone is insufficient.
|
||||
`parameters` object for commands without flags. Keep it suitable for the #602
|
||||
compatibility baseline and fail rather than silently emitting a partial
|
||||
export.
|
||||
- `schema --all` is not normal command discovery. Use overview -> product/group
|
||||
-> leaf for routine Agent work. `--compact` is supported for context-saving
|
||||
projections, but a compact full export is not a complete compatibility
|
||||
baseline.
|
||||
- `schema --all` is not normal command discovery. Use overview -> compact
|
||||
product/group -> compact leaf for routine Agent work. `--compact` is the
|
||||
reviewed positive-field allowlist for Agent context: new full/audit fields
|
||||
must not appear there until explicitly reviewed. A compact full export is not
|
||||
a complete compatibility baseline.
|
||||
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||
executable accepts. A leaf Schema defines Agent selection, parameter mapping
|
||||
and constraints, and safety/confirmation semantics. A conflict is contract
|
||||
drift, not permission to guess.
|
||||
executable accepts. A compact leaf defines Agent selection, CLI parameters,
|
||||
constraints, and safety/confirmation semantics. Full leaf fields such as
|
||||
`property`, `interface_ref`, and provenance are audit facts. A conflict is
|
||||
contract drift, not permission to guess.
|
||||
- Schema and Help describe commands; neither returns DingTalk business data.
|
||||
After discovery, execute the real read/search/list command to obtain data.
|
||||
|
||||
@@ -6,6 +6,60 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
|
||||
the registered remote tool name `verify_formula`; the previous
|
||||
`formula_verify` name failed at gateway dispatch.
|
||||
|
||||
## [1.0.57-beta.4] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Expanded open CLI workflows** (#887) — adds calendar event-instance
|
||||
queries, Drive latest-file selection, Markdown diff, Mail calendar/export/
|
||||
share-to-chat workflows, and Minutes hot-word, permission, and audio-memo
|
||||
operations, with matching Schema and cross-platform coverage.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Multi-skill framework alignment** (#887) — folds long-tail skills into
|
||||
`dingtalk-misc`, renames the shared package to `dingtalk-shared`, removes
|
||||
stale Preview guidance, and reorganizes shared recipes and routing for more
|
||||
predictable Agent selection.
|
||||
- **Bounded Agent Schema delivery** (#887) — keeps compact and wire projections
|
||||
focused on executable contract facts, retires stale MCP metadata candidates,
|
||||
and teaches Agents to prefer `dws schema --compact` for bounded context.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- **Recovery and discovery-cache compatibility surfaces** (#887) — keeps
|
||||
visible Deprecated `dws recovery` and `dws cache` compatibility stubs while
|
||||
retiring their former recovery engine and dynamic discovery-cache behavior.
|
||||
Recovery plan/execute/finalize now return an explicit “不再支持” notice, and
|
||||
Skills no longer teach either retired workflow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mail share-to-chat confirmation** (#887) — requires explicit confirmation
|
||||
before the first remote write, while preserving the confirmed sign-retry
|
||||
flow and covering both direct-success and retry responses.
|
||||
|
||||
## [1.0.57] - 2026-08-06
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.57-beta.4` baseline.
|
||||
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
|
||||
and compatibility improvements, plus the multi-skill framework alignment and
|
||||
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
|
||||
the final prerelease.
|
||||
|
||||
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
|
||||
baseline as stable `v1.0.57` without adding post-beta product changes.
|
||||
|
||||
## [1.0.57-beta.3] - 2026-08-06
|
||||
|
||||
### Added
|
||||
@@ -82,6 +136,7 @@ and compatibility and CI reliability fixes.
|
||||
### Added
|
||||
|
||||
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
|
||||
- **Robot image and file messages** — extends `chat message send-by-bot` with public image URL delivery through `--msg-type image --image-url` and local-file upload/send through `--msg-type file --file-path`, while preserving Markdown as the default message type.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.57-beta.2"
|
||||
version "1.0.57-beta.4"
|
||||
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.57-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "2119754d4c6f6be2b4856ab559ad44ac582a3b3abc76ff907927f62c7a4a3d29"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "ff363e258d463732e4dc02aa71ac1b5b1f05c25107c32ddcc5ae11d14931a782"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a453341d6df1a78b7d74bd624842503d857a41f73fa1ac36394e4594e4961e8d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3eafcc4c27931b8457f3611a12ce4ae727f7bd65356fbef80958699defa09acf"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "734df2c7f34ca36aa48151fda2b18e1c2c90fe812fb5ab13e8c00e074cca43af"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "49eabb4d2c419d8d1fb0fcf71d9c318ef49cf3d198431df1bcbaa71589e11383"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "f602a63ab6afd2e24db7b7dabfddb0cdcf3a7bd55b0cc60a99013bac5cacc56f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "fb3903485fe494e1fadb67ec87b2fef19cf575c72b7df25f0b93f80f8b36273f"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.2/dws-skills.zip"
|
||||
sha256 "486f5ef30a88a293c14df1ff0768760284179993c51f898fa2bee2c9391d8607"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57-beta.4/dws-skills.zip"
|
||||
sha256 "6bd11363dbd2ce79627f49bc80b8a92f9c79fca083a6bd3ff9bc5c4833594fb9"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.56"
|
||||
version "1.0.57"
|
||||
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.56/dws-darwin-arm64.tar.gz"
|
||||
sha256 "5c6003fe484aa36cc00820a574186652467b9d075f19c159cf807e57590256ba"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
|
||||
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-darwin-amd64.tar.gz"
|
||||
sha256 "969b005a10682c2a1a828fa112165b5b0cd8ceeed8d22110ef7f39402cc36804"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
|
||||
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-arm64.tar.gz"
|
||||
sha256 "530c5ea7ddc7de320d9c2471fbd33752a723d00c9665f49321c7580e8392c756"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
|
||||
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-linux-amd64.tar.gz"
|
||||
sha256 "675fa42727ac9a549c6710b82e1980cd0f795363d71d5116a4e69771b7c5470e"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
|
||||
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.56/dws-skills.zip"
|
||||
sha256 "3d57794e4660a089209ce3962571d16ca0d46141e973c9993257a301cce0e097"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
|
||||
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -131,6 +131,9 @@ skill-context-budget:
|
||||
multi-im-skill-chain-integrity:
|
||||
@./scripts/policy/check-multi-im-skill-chain.sh
|
||||
|
||||
skill-mono-multi-content:
|
||||
@./scripts/policy/check-mono-multi-skill-content.sh
|
||||
|
||||
cli-smoke:
|
||||
@./scripts/policy/check-cli-smoke.sh
|
||||
|
||||
@@ -148,9 +151,13 @@ generate-schema:
|
||||
@set -e; \
|
||||
concepts_guard=$$(mktemp); \
|
||||
concepts_schema_guard=$$(mktemp); \
|
||||
trap 'rm -rf "$$concepts_guard" "$$concepts_schema_guard"' EXIT HUP INT TERM; \
|
||||
command_fallbacks_guard=$$(mktemp); \
|
||||
command_fallbacks_schema_guard=$$(mktemp); \
|
||||
trap 'rm -f "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard"' EXIT HUP INT TERM; \
|
||||
cp internal/cli/param_concepts.json "$$concepts_guard"; \
|
||||
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
|
||||
cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \
|
||||
cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \
|
||||
$(GO) generate ./internal/cli; \
|
||||
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
|
||||
rm -f internal/cli/schema_meta_index.json; \
|
||||
@@ -166,6 +173,14 @@ generate-schema:
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
if [ -e internal/cli/schema_hints ]; then \
|
||||
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
|
||||
exit 1; \
|
||||
|
||||
@@ -71,9 +71,7 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -371,7 +369,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
Use Cobra help and Schema for different parts of the command contract:
|
||||
|
||||
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
|
||||
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
|
||||
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
|
||||
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
|
||||
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
|
||||
|
||||
@@ -380,21 +378,21 @@ Use Cobra help and Schema for different parts of the command contract:
|
||||
dws aitable record query --help
|
||||
|
||||
# Discover within a product, then inspect the selected leaf contract
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
dws schema aitable --compact
|
||||
dws schema "aitable record query" --compact
|
||||
|
||||
# Execute the real business query
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
|
||||
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
@@ -443,7 +441,6 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
|
||||
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
|
||||
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
|
||||
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
|
||||
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
|
||||
|
||||
<details>
|
||||
@@ -539,7 +536,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
||||
|
||||
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
|
||||
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -624,7 +621,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# Built-in jq expressions
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
dws schema "dev app create" --jq '.parameters'
|
||||
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -636,9 +633,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
|
||||
|
||||
```bash
|
||||
dws schema aitable # discover product commands
|
||||
dws schema "aitable record query" # view the selected leaf contract
|
||||
dws schema "aitable record query" --jq '.tool.required' # view required fields
|
||||
dws schema aitable --compact # discover product commands
|
||||
dws schema "aitable record query" --compact # view the selected Agent leaf contract
|
||||
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
|
||||
dws schema --all # full export for CI/audit/baselines
|
||||
```
|
||||
|
||||
|
||||
+11
-14
@@ -71,9 +71,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -365,7 +363,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
命令帮助和 Schema 分别负责命令契约的不同部分:
|
||||
|
||||
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
|
||||
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
|
||||
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
|
||||
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
|
||||
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
|
||||
|
||||
@@ -374,21 +372,21 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
dws aitable record query --help
|
||||
|
||||
# 先在产品内发现命令,再查看选中 leaf 的契约
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
dws schema aitable --compact
|
||||
dws schema "aitable record query" --compact
|
||||
|
||||
# 执行真实业务查询
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
|
||||
|
||||
### Agent Skills
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
|
||||
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
@@ -437,7 +435,6 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
|
||||
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
|
||||
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||
|
||||
<details>
|
||||
@@ -533,7 +530,7 @@ dws event stop <subscribe_id>
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -618,7 +615,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# 内置 jq 表达式
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
dws schema "dev app create" --jq '.parameters'
|
||||
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -630,9 +627,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
|
||||
|
||||
```bash
|
||||
dws schema aitable # 发现产品命令
|
||||
dws schema "aitable record query" # 查看选中 leaf 契约
|
||||
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
|
||||
dws schema aitable --compact # 发现产品命令
|
||||
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
|
||||
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
|
||||
dws schema --all # CI/审计/基线的全量导出
|
||||
```
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@
|
||||
- `internal/output`: response formatting (json, table, raw, pretty)
|
||||
- `internal/logging`: structured logging and argument sanitization
|
||||
- `internal/tui`: terminal UI helpers
|
||||
- `internal/recovery`: panic recovery and graceful degradation
|
||||
- `pkg/configmeta`: environment variable registry and documentation
|
||||
- `pkg/config`: configuration constants and paths
|
||||
- `pkg/edition`: edition detection (oss vs enterprise)
|
||||
|
||||
+1
-1
@@ -43,7 +43,7 @@ repository root while preserving repo-local guidance for automation.
|
||||
- Error message or category issues: inspect `internal/errors`
|
||||
- Audit log issues: inspect `internal/audit`
|
||||
- Plugin loading or command surface: inspect `internal/plugin`
|
||||
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
|
||||
- Failure or degraded mode: inspect `internal/errors`
|
||||
|
||||
## Policy Checks
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
|
||||
|
||||
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
|
||||
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
|
||||
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
|
||||
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
|
||||
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 一键安装
|
||||
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
@@ -24,7 +24,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
|
||||
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
|
||||
2. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
|
||||
|
||||
支持这些环境变量(全部可选):
|
||||
|
||||
@@ -33,7 +33,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
|
||||
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
||||
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-misc` skill 安装 |
|
||||
|
||||
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
|
||||
|
||||
|
||||
+11
-10
@@ -94,7 +94,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
|
||||
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
|
||||
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
```
|
||||
|
||||
## Dry Run / 试运行
|
||||
@@ -119,27 +119,28 @@ Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树
|
||||
|
||||
```bash
|
||||
dws schema # 当前公开产品面的紧凑概览
|
||||
dws schema calendar # 展开一个产品
|
||||
dws schema "calendar event" # 展开一个命令分组
|
||||
dws schema "calendar event create" # 按 CLI 空格路径查询工具
|
||||
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
|
||||
dws schema --cli-path "calendar event create" # 显式 CLI path
|
||||
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
|
||||
dws schema calendar --compact # Agent 产品视图
|
||||
dws schema "calendar event" --compact # Agent 分组视图
|
||||
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
|
||||
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
|
||||
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
|
||||
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
|
||||
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
|
||||
```
|
||||
|
||||
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
|
||||
|
||||
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
|
||||
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
|
||||
|
||||
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
|
||||
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
|
||||
|
||||
### Schema、Help 与业务数据的边界
|
||||
|
||||
| 问题 | 事实源 |
|
||||
|------|--------|
|
||||
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
|
||||
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
|
||||
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
|
||||
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
|
||||
@@ -231,7 +231,8 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
|
||||
| 问题 | 事实源 |
|
||||
|---|---|
|
||||
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
|
||||
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
|
||||
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
|
||||
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||
@@ -246,10 +247,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||
|
||||
```bash
|
||||
dws schema # 产品紧凑概览
|
||||
dws schema calendar # 产品摘要
|
||||
dws schema "calendar event" # 分组摘要
|
||||
dws schema "calendar event create" # 完整 leaf
|
||||
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
|
||||
dws schema calendar --compact # Agent 产品摘要
|
||||
dws schema "calendar event" --compact # Agent 分组摘要
|
||||
dws schema "calendar event create" --compact # Agent leaf
|
||||
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
|
||||
dws schema --all # 所有工具的完整 leaf 导出
|
||||
```
|
||||
|
||||
@@ -257,7 +258,7 @@ dws schema --all # 所有工具的完整 leaf 导
|
||||
|
||||
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
|
||||
|
||||
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
|
||||
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
|
||||
|
||||
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
# DWS Skill 内容框架合同
|
||||
|
||||
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
|
||||
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
|
||||
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
|
||||
|
||||
## 1. 两棵内容树
|
||||
|
||||
| 树 | 路径 | 角色 |
|
||||
|---|---|---|
|
||||
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
|
||||
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
|
||||
|
||||
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
|
||||
|
||||
## 2. Multi 目录合同(如何新增一个产品 skill)
|
||||
|
||||
新建 `skills/multi/<name>/` 时必须满足:
|
||||
|
||||
1. **命名**
|
||||
- 产品 skill:`dingtalk-<product>`(小写、连字符)
|
||||
- 共享 skill:仅允许 `dingtalk-shared`
|
||||
2. **根文件**
|
||||
- 必有 `SKILL.md`(YAML frontmatter + 正文)
|
||||
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
|
||||
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
|
||||
3. **Frontmatter 最小集**(产品 / shared)
|
||||
- `name`:与目录名一致
|
||||
- `description`:非空,含触发意图与边界
|
||||
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
|
||||
- `metadata.requires.bins`:含 `dws`
|
||||
4. **契约块**
|
||||
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
|
||||
- `dingtalk-shared` 承载跨产品路由与全局协议落点
|
||||
5. **与 mono 映射**
|
||||
- 每个 mono `references/products/<stem>`(文件或目录)必须在
|
||||
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
|
||||
|
||||
### 2.1 推荐骨架
|
||||
|
||||
```text
|
||||
skills/multi/dingtalk-example/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── example.md # 主产品面
|
||||
│ └── … # 子章节 / 意图表
|
||||
└── scripts/ # 可选;须被 md 引用
|
||||
└── example_helper.py
|
||||
```
|
||||
|
||||
## 3. Mono 目录合同(质检对照基准)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/ # 覆盖质检主源
|
||||
│ ├── error-codes.md # 全局协议示例
|
||||
│ ├── error-codes.md
|
||||
│ └── …
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
|
||||
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
|
||||
|
||||
## 4. 共享内容(`dingtalk-shared`)
|
||||
|
||||
| 职责 | 落点 |
|
||||
|---|---|
|
||||
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
|
||||
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
|
||||
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
|
||||
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
|
||||
|
||||
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
|
||||
|
||||
## 5. Zip 内容布局合同(形状,非安装默认)
|
||||
|
||||
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
|
||||
|
||||
| Zip 路径 | 含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 内容副本(兼容旧面) |
|
||||
| `<root>/mono/` | 与 `skills/mono/` 同构 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言源树形状;**不**断言安装器默认解压哪棵。
|
||||
|
||||
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
|
||||
|
||||
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
|
||||
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
|
||||
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
|
||||
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
|
||||
|
||||
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
|
||||
|
||||
## 7. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(或 `make policy`)
|
||||
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
|
||||
@@ -0,0 +1,65 @@
|
||||
# Mono↔Multi Skill 内容质检规格
|
||||
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(已挂入 `make policy`)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
| ID | 类型 | 输入 | 通过准则 |
|
||||
|---|---|---|---|
|
||||
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
|
||||
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
|
||||
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 一致;全局协议存在或 ∈ `omit_global` |
|
||||
|
||||
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
|
||||
|
||||
## 2. 有意省略 / 延期登记格式
|
||||
|
||||
YAML(见 coverage 文件):
|
||||
|
||||
```yaml
|
||||
omit_coverage:
|
||||
- mono: simple
|
||||
disposition: covered_by # covered_by | defer | wontfix
|
||||
via: dingtalk-misc # optional
|
||||
reason: "拆入 oa/devdoc…"
|
||||
|
||||
omit_global:
|
||||
- id: field-rules-global
|
||||
mono_path: references/field-rules.md
|
||||
expected_multi: dingtalk-aitable/references/field-rules.md
|
||||
disposition: covered_by
|
||||
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
|
||||
|
||||
orphan_scripts_allowlist:
|
||||
- path: dingtalk-misc/scripts/report_received_today.py
|
||||
disposition: defer
|
||||
reason: "pending report.md reference"
|
||||
```
|
||||
|
||||
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
|
||||
|
||||
## 3. 缺口基线(相对 mono)
|
||||
|
||||
| ID | 项 | disposition | 说明 |
|
||||
|---|---|---|---|
|
||||
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
|
||||
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
|
||||
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
|
||||
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
|
||||
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
|
||||
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
|
||||
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
|
||||
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
|
||||
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
|
||||
| X4 | event 缺 metadata | **done** | |
|
||||
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
|
||||
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
|
||||
|
||||
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
|
||||
|
||||
## 4. 与悟空
|
||||
|
||||
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
|
||||
@@ -0,0 +1,272 @@
|
||||
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
|
||||
|
||||
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(已入 `make policy`)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
|
||||
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
|
||||
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
|
||||
>
|
||||
> 对照仓:
|
||||
>
|
||||
> | 仓 | 路径 | 基线 |
|
||||
> |---|---|---|
|
||||
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
|
||||
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
|
||||
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
|
||||
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
|
||||
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
|
||||
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
|
||||
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
|
||||
|
||||
### 0.1 IN SCOPE
|
||||
|
||||
| 类别 | 包含 |
|
||||
|---|---|
|
||||
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
|
||||
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
|
||||
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
|
||||
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
|
||||
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
|
||||
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
|
||||
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
|
||||
|
||||
### 0.2 OUT OF SCOPE
|
||||
|
||||
| 类别 | 去向 |
|
||||
|---|---|
|
||||
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
|
||||
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
|
||||
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
|
||||
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
|
||||
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
|
||||
| 把质检做成「改安装默认值」的后门 | 拒绝 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 内容现状盘点
|
||||
|
||||
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
|
||||
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
|
||||
│ └── best_practices/…
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
### 1.2 DWS `skills/multi`(内容主体)
|
||||
|
||||
```text
|
||||
skills/multi/
|
||||
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
|
||||
└── dingtalk-*/ # 19 产品 + 各 references、scripts
|
||||
```
|
||||
|
||||
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
|
||||
|
||||
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
|
||||
|
||||
### 1.4 Zip 内容布局合同
|
||||
|
||||
| Zip 路径 | 内容含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 副本(兼容) |
|
||||
| `<root>/mono/` | 显式 mono 内容源 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
|
||||
|
||||
### 1.5 现有 DWS skill 内容质检资产(复用清单)
|
||||
|
||||
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|
||||
|---|---|---|
|
||||
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
|
||||
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
|
||||
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
|
||||
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
|
||||
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
|
||||
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
|
||||
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
|
||||
|
||||
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
|
||||
|
||||
### 1.6 悟空侧类比质检
|
||||
|
||||
| 悟空 | 说明 | 本分支 |
|
||||
|---|---|---|
|
||||
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
|
||||
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
|
||||
|
||||
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
|
||||
|
||||
---
|
||||
|
||||
## 2. Diff(内容组织 + 质检视角)
|
||||
|
||||
### 2.1 已同构
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
|
||||
|
||||
### 2.2 分叉与已知内容风险(质检要盯的)
|
||||
|
||||
| 风险 ID | 现象(线索) | 质检类型 |
|
||||
|---|---|---|
|
||||
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
|
||||
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
|
||||
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
|
||||
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
|
||||
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
|
||||
|
||||
### 2.3 Reject
|
||||
|
||||
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals / Non-goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
|
||||
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
|
||||
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
|
||||
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
|
||||
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
|
||||
|
||||
### 3.2 Non-goals
|
||||
|
||||
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
|
||||
|
||||
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
|
||||
|
||||
### Phase 0 — 方案冻结(本文)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 本文件;§7(含质检轨)勾选 |
|
||||
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
|
||||
|
||||
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
|
||||
| **触达** | `docs/skill-content-framework.md` |
|
||||
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
|
||||
|
||||
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
|
||||
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
|
||||
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
|
||||
|
||||
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | G1–G4 自动门禁 |
|
||||
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
|
||||
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
|
||||
|
||||
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
|
||||
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
|
||||
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
|
||||
|
||||
### 延期登记(非本分支)
|
||||
|
||||
| 主题 | 载体 |
|
||||
|---|---|
|
||||
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
|
||||
| skillhome / 安装面 bootstrap | 行为分支 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Port / Adapt / Reject
|
||||
|
||||
| 项 | 决策 | 说明 |
|
||||
|---|---|---|
|
||||
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
|
||||
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
|
||||
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
|
||||
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
|
||||
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
|
||||
| 行为 cherry-pick | **Defer** | 另分支 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 `402429ac` / `d5c8982c`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 本分支 cherry-pick? | **否** |
|
||||
| 质检是否替代行为翻转? | **否** |
|
||||
| 行为分支 | 另开;可与内容/质检并行 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 批准清单(请重新勾选)
|
||||
|
||||
**范围**
|
||||
|
||||
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
|
||||
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
|
||||
- [x] 取消产品与悟空客户端链路仍拒绝
|
||||
|
||||
**内容框架 Phase**
|
||||
|
||||
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
|
||||
|
||||
**质检轨 Phase**
|
||||
|
||||
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
|
||||
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
|
||||
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
|
||||
|
||||
**可选**
|
||||
|
||||
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
|
||||
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
|
||||
|
||||
**Follow-up 知悉**
|
||||
|
||||
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
|
||||
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
|
||||
安装默认 multi 等行为仍走 **另一分支**。
|
||||
|
||||
---
|
||||
|
||||
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
|
||||
@@ -335,12 +335,6 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
||||
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
|
||||
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc", "download", "--node", "n"}
|
||||
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
|
||||
t.Fatalf("runtime command path = %#v", got)
|
||||
}
|
||||
os.Args = oldArgs
|
||||
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
if hostControlProviderFromEnv() != "" {
|
||||
@@ -360,6 +354,10 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
||||
|
||||
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
|
||||
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
|
||||
command.SetOut(io.Discard)
|
||||
rootWrap := &cobra.Command{Use: "dws"}
|
||||
rootWrap.PersistentFlags().String("format", "json", "")
|
||||
rootWrap.AddCommand(command)
|
||||
command.SetOut(io.Discard)
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -389,18 +387,18 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
_ = cacheRoot.PersistentFlags().Set("format", format)
|
||||
cacheCmd.SetOut(io.Discard)
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fail := errors.New("write")
|
||||
cacheCmd.SetOut(appFailWriter{err: fail})
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("pretty write error = %v", err)
|
||||
}
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "table")
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("table write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const cacheUnsupportedMessage = "dws cache 不再支持:服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。"
|
||||
|
||||
const cacheReplacementHint = "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。"
|
||||
|
||||
type cacheCompatNotice struct {
|
||||
Status string `json:"status"`
|
||||
Command string `json:"command"`
|
||||
@@ -28,24 +32,32 @@ type cacheCompatNotice struct {
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
}
|
||||
|
||||
// newCacheCommand keeps a visible Deprecated compatibility surface for
|
||||
// historical argv (refresh/status/clean). Behavior is a successful no-op notice.
|
||||
// Skills must not teach this path. Deprecated leaves are excluded from Schema
|
||||
// via cobra.IsAvailableCommand() — do not add schema_command_exclusions entries.
|
||||
func newCacheCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cache",
|
||||
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
|
||||
Hidden: true,
|
||||
Short: "不再支持:服务发现缓存兼容入口",
|
||||
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
|
||||
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
return printCacheCompatNotice(cmd, "dws cache")
|
||||
},
|
||||
}
|
||||
for _, name := range []string{"refresh", "status", "clean"} {
|
||||
subName := name
|
||||
sub := &cobra.Command{
|
||||
Use: name,
|
||||
Short: "已弃用:静态端点模式无需服务发现缓存",
|
||||
Use: subName,
|
||||
Short: "不再支持:静态端点模式无需服务发现缓存",
|
||||
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return printCacheCompatNotice(cmd, name)
|
||||
return printCacheCompatNotice(cmd, "dws cache "+subName)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(sub)
|
||||
@@ -56,9 +68,9 @@ func newCacheCommand() *cobra.Command {
|
||||
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
notice := cacheCompatNotice{
|
||||
Status: "deprecated",
|
||||
Command: "dws cache " + command,
|
||||
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
|
||||
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
|
||||
Command: command,
|
||||
Message: cacheUnsupportedMessage,
|
||||
Replacement: cacheReplacementHint,
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
@@ -66,8 +78,7 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
||||
case "pretty":
|
||||
data, _ := json.MarshalIndent(notice, "", " ")
|
||||
var err error
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return err
|
||||
default:
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCacheDeprecatedCompatShim(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
group := mustFindCommand(t, root, "cache")
|
||||
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
|
||||
t.Fatalf("cache group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
|
||||
}
|
||||
if group.IsAvailableCommand() {
|
||||
t.Fatal("deprecated cache group must not be IsAvailableCommand")
|
||||
}
|
||||
|
||||
for _, leaf := range []string{"refresh", "status", "clean"} {
|
||||
cmd := mustFindCommand(t, root, "cache", leaf)
|
||||
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
|
||||
t.Fatalf("cache %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
|
||||
}
|
||||
if cmd.IsAvailableCommand() {
|
||||
t.Fatalf("deprecated cache %s must not be IsAvailableCommand", leaf)
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "不再支持", "服务发现已下线"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"", "json", "pretty", "table"} {
|
||||
var buf bytes.Buffer
|
||||
parent := &cobra.Command{Use: "dws"}
|
||||
parent.PersistentFlags().String("format", format, "")
|
||||
parent.SetOut(&buf)
|
||||
sub := &cobra.Command{Use: "cache"}
|
||||
parent.AddCommand(sub)
|
||||
if err := printCacheCompatNotice(sub, "dws cache status"); err != nil {
|
||||
t.Fatalf("format=%q: %v", format, err)
|
||||
}
|
||||
text := buf.String()
|
||||
if !strings.Contains(text, "不再支持") && !strings.Contains(text, "服务发现已下线") {
|
||||
t.Fatalf("format=%q missing notice:\n%s", format, text)
|
||||
}
|
||||
if format == "" || format == "json" || format == "pretty" {
|
||||
if !strings.Contains(text, `"status":"deprecated"`) && !strings.Contains(text, `"status": "deprecated"`) {
|
||||
t.Fatalf("format=%q missing deprecated JSON status:\n%s", format, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"pretty", "table"} {
|
||||
parent := &cobra.Command{Use: "dws"}
|
||||
parent.PersistentFlags().String("format", format, "")
|
||||
parent.SetOut(failWriter{})
|
||||
sub := &cobra.Command{Use: "cache"}
|
||||
parent.AddCommand(sub)
|
||||
if err := printCacheCompatNotice(sub, "dws cache clean"); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
|
||||
}
|
||||
}
|
||||
|
||||
parent := newCacheCommand()
|
||||
var parentOut bytes.Buffer
|
||||
rootWrap := &cobra.Command{Use: "dws"}
|
||||
rootWrap.PersistentFlags().String("format", "json", "")
|
||||
rootWrap.SetOut(&parentOut)
|
||||
rootWrap.AddCommand(parent)
|
||||
parent.SetOut(&parentOut)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatalf("cache parent RunE = %v, want nil success", err)
|
||||
}
|
||||
if !strings.Contains(parentOut.String(), `"command":"dws cache"`) {
|
||||
t.Fatalf("cache parent notice missing command:\n%s", parentOut.String())
|
||||
}
|
||||
|
||||
cache := newCacheCommand()
|
||||
cache.SetOut(&bytes.Buffer{})
|
||||
cache.SetArgs([]string{"status"})
|
||||
if err := cache.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
//go:embed testdata/shortcut_hallucination_badcases_20260728.json
|
||||
var shortcutHallucinationBadcases20260728JSON []byte
|
||||
|
||||
type shortcutHallucinationReplayCorpus struct {
|
||||
SourceReport string `json:"source_report"`
|
||||
SourceFile string `json:"source_file"`
|
||||
SourceDWSCommit string `json:"source_dws_commit"`
|
||||
Model string `json:"model"`
|
||||
ExpectedCount int `json:"expected_count"`
|
||||
Badcases []shortcutHallucinationReplayBadcase `json:"badcases"`
|
||||
}
|
||||
|
||||
type shortcutHallucinationReplayBadcase struct {
|
||||
ID string `json:"id"`
|
||||
CaseID string `json:"case_id"`
|
||||
Run int `json:"run"`
|
||||
CommandIndex int `json:"command_index"`
|
||||
Turn int `json:"turn"`
|
||||
SourcePath string `json:"source_path"`
|
||||
ExpectedOutcome string `json:"expected_outcome"`
|
||||
Raw string `json:"raw"`
|
||||
OriginalExitCode int `json:"original_exit_code"`
|
||||
IsHelp bool `json:"is_help"`
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageShortcutHallucinationBadcases20260728Replay(t *testing.T) {
|
||||
var corpus shortcutHallucinationReplayCorpus
|
||||
if err := json.Unmarshal(shortcutHallucinationBadcases20260728JSON, &corpus); err != nil {
|
||||
t.Fatalf("decode historical shortcut badcase corpus: %v", err)
|
||||
}
|
||||
if corpus.SourceReport != "param_hallucination_20260728_120943" ||
|
||||
corpus.SourceFile != "raw_dashscope_qwen3_7-max_20260728_120943.json" ||
|
||||
corpus.SourceDWSCommit != "a8e83e5" || corpus.Model != "dashscope/qwen3.7-max" {
|
||||
t.Fatalf("unexpected historical corpus provenance: %#v", corpus)
|
||||
}
|
||||
if corpus.ExpectedCount != 52 || len(corpus.Badcases) != corpus.ExpectedCount {
|
||||
t.Fatalf("historical shortcut badcases = %d, expected_count=%d; want 52", len(corpus.Badcases), corpus.ExpectedCount)
|
||||
}
|
||||
|
||||
wantOutcomeCounts := map[string]int{
|
||||
"rewrite": 8,
|
||||
"ambiguous": 44,
|
||||
}
|
||||
wantSourceCounts := map[string]int{
|
||||
"chat +group-member-list": 1,
|
||||
"chat +group-send-text": 1,
|
||||
"chat +list-group-bots": 1,
|
||||
"chat +list-robot": 1,
|
||||
"chat +list-robots": 1,
|
||||
"chat +members": 3,
|
||||
"chat +message-list": 1,
|
||||
"chat +read-single": 6,
|
||||
"chat +rename-group": 1,
|
||||
"chat +send": 4,
|
||||
"chat +send-by-bot": 3,
|
||||
"chat +send-dm": 2,
|
||||
"chat +send-file": 15,
|
||||
"chat +send-image": 3,
|
||||
"chat +send-media": 2,
|
||||
"chat +send-message": 3,
|
||||
"chat +send-single": 1,
|
||||
"chat +send-text": 2,
|
||||
"chat +send-to": 1,
|
||||
}
|
||||
rewriteTargets := map[string]string{
|
||||
"chat +members": "chat +group-members",
|
||||
"chat +group-member-list": "chat +group-members",
|
||||
"chat +list-group-bots": "chat +chat-bots",
|
||||
"chat +list-robot": "chat +chat-bots",
|
||||
"chat +list-robots": "chat +chat-bots",
|
||||
"chat +rename-group": "chat +chat-update",
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]bool, len(corpus.Badcases))
|
||||
gotOutcomeCounts := make(map[string]int)
|
||||
gotSourceCounts := make(map[string]int)
|
||||
for _, badcase := range corpus.Badcases {
|
||||
badcase := badcase
|
||||
t.Run(badcase.ID, func(t *testing.T) {
|
||||
if badcase.ID == "" || seenIDs[badcase.ID] {
|
||||
t.Fatalf("missing or duplicate historical badcase id %q", badcase.ID)
|
||||
}
|
||||
seenIDs[badcase.ID] = true
|
||||
gotOutcomeCounts[badcase.ExpectedOutcome]++
|
||||
gotSourceCounts[badcase.SourcePath]++
|
||||
|
||||
args := historicalShortcutBadcaseArgv(t, badcase.Raw)
|
||||
if len(args) < 2 || strings.Join(args[:2], " ") != badcase.SourcePath {
|
||||
t.Fatalf("raw argv source = %v, fixture source_path=%q", args, badcase.SourcePath)
|
||||
}
|
||||
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
switch badcase.ExpectedOutcome {
|
||||
case "rewrite":
|
||||
assertHistoricalShortcutRewrite(t, badcase, rewriteTargets[badcase.SourcePath], ctx, err)
|
||||
case "ambiguous":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
|
||||
entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath)
|
||||
if !ok || entry.Mode != cli.CommandPathFallbackAmbiguous || len(entry.Candidates) < 2 {
|
||||
t.Fatalf("ambiguous fallback table entry = %#v, %v", entry, ok)
|
||||
}
|
||||
case "unknown_shortcut":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "unknown_shortcut")
|
||||
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
|
||||
t.Fatalf("contract-incomplete path unexpectedly entered fallback table: %#v", entry)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unsupported expected_outcome %q", badcase.ExpectedOutcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !reflect.DeepEqual(gotOutcomeCounts, wantOutcomeCounts) {
|
||||
t.Errorf("historical outcome counts = %v, want %v", gotOutcomeCounts, wantOutcomeCounts)
|
||||
}
|
||||
if !reflect.DeepEqual(gotSourceCounts, wantSourceCounts) {
|
||||
t.Errorf("historical source counts = %v, want %v", gotSourceCounts, wantSourceCounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageShortcutHallucinationMerged20260720Replay(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
raw string
|
||||
occurrences int
|
||||
outcome string
|
||||
target string
|
||||
}{
|
||||
{
|
||||
id: "dws_im_v2_0013-search-group",
|
||||
raw: `dws chat +search-group --name "dws测试群02" --format json`,
|
||||
occurrences: 1,
|
||||
outcome: "official_alias",
|
||||
target: "chat +chat-search",
|
||||
},
|
||||
{
|
||||
id: "dws_oa_0017-list-processes",
|
||||
raw: `dws oa +list-processes --format json --limit 50`,
|
||||
occurrences: 2,
|
||||
outcome: "ambiguous",
|
||||
},
|
||||
}
|
||||
totalOccurrences := 0
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.id, func(t *testing.T) {
|
||||
totalOccurrences += test.occurrences
|
||||
args := historicalShortcutBadcaseArgv(t, test.raw)
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
badcase := shortcutHallucinationReplayBadcase{
|
||||
ID: test.id,
|
||||
SourcePath: strings.Join(args[:2], " "),
|
||||
ExpectedOutcome: test.outcome,
|
||||
Raw: test.raw,
|
||||
}
|
||||
switch test.outcome {
|
||||
case "rewrite":
|
||||
assertHistoricalShortcutRewrite(t, badcase, test.target, ctx, err)
|
||||
case "official_alias":
|
||||
assertHistoricalOfficialAlias(t, badcase, test.target, ctx, err)
|
||||
case "ambiguous":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
|
||||
default:
|
||||
t.Fatalf("unsupported merged expected outcome %q", test.outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
if totalOccurrences != 3 {
|
||||
t.Fatalf("merged shortcut hallucination occurrences = %d, want 3", totalOccurrences)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHistoricalOfficialAlias(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
wantTarget string,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("historical official alias %q returned error: %v", badcase.SourcePath, err)
|
||||
}
|
||||
if wantTarget == "" || ctx == nil || ctx.Command != "dws "+wantTarget {
|
||||
t.Fatalf("historical official alias context = %#v; want command dws %s", ctx, wantTarget)
|
||||
}
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("historical official alias received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
|
||||
t.Fatalf("official alias unexpectedly remains in fallback table: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func historicalShortcutBadcaseArgv(t *testing.T, raw string) []string {
|
||||
t.Helper()
|
||||
command := strings.TrimSpace(raw)
|
||||
for _, suffix := range []string{" 2>&1 | head -50", " 2>&1"} {
|
||||
command = strings.TrimSuffix(command, suffix)
|
||||
}
|
||||
argv, err := cli.ParseAgentExampleArgv(command)
|
||||
if err != nil {
|
||||
t.Fatalf("parse historical raw command %q without a shell: %v", raw, err)
|
||||
}
|
||||
if len(argv) < 3 || argv[0] != "dws" {
|
||||
t.Fatalf("historical raw command did not produce dws argv: %q => %v", raw, argv)
|
||||
}
|
||||
return append([]string(nil), argv[1:]...)
|
||||
}
|
||||
|
||||
func assertHistoricalShortcutRewrite(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
wantTarget string,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
if wantTarget == "" {
|
||||
t.Fatalf("historical rewrite %q has no reviewed target", badcase.SourcePath)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws "+wantTarget {
|
||||
t.Fatalf("historical rewrite context = %#v, error=%v; want command dws %s", ctx, err, wantTarget)
|
||||
}
|
||||
found := false
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" && correction.Original == badcase.SourcePath && correction.Corrected == wantTarget {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("historical rewrite corrections = %#v; want %q -> %q", ctx.Corrections, badcase.SourcePath, wantTarget)
|
||||
}
|
||||
wantPrefix := strings.Fields(wantTarget)
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("historical rewrite argv = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
if reason := structuredShortcutReplayReason(err); reason == "unknown_shortcut" || reason == "ambiguous_command_fallback" {
|
||||
t.Fatalf("historical rewrite returned command-resolution reason %q: %v", reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHistoricalShortcutReason(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
wantReason string,
|
||||
) {
|
||||
t.Helper()
|
||||
if ctx == nil {
|
||||
t.Fatalf("historical %s badcase returned nil context: %v", wantReason, err)
|
||||
}
|
||||
if got := structuredShortcutReplayReason(err); got != wantReason {
|
||||
t.Fatalf("historical badcase %q reason = %q, error=%v; want %q (original exit=%d help=%v)", badcase.Raw, got, err, wantReason, badcase.OriginalExitCode, badcase.IsHelp)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(fmt.Sprint(err)), "unknown flag") {
|
||||
t.Fatalf("historical badcase %q regressed to unknown flag: %v", badcase.Raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func structuredShortcutReplayReason(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(err, &structured) {
|
||||
return structured.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeShortcutFallbackChildEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CHILD"
|
||||
runtimeShortcutFallbackConfigEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CONFIG_DIR"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback(t *testing.T) {
|
||||
if os.Getenv(runtimeShortcutFallbackChildEnv) == "1" {
|
||||
t.Setenv("DWS_CONFIG_DIR", os.Getenv(runtimeShortcutFallbackConfigEnv))
|
||||
engine := newPipelineEngine()
|
||||
root := NewRootCommandWithEngine(context.Background(), engine)
|
||||
runtimeCommand := exactAppCommand(root, "chat +members")
|
||||
if runtimeCommand == nil || !runtimeCommand.Runnable() || cmdutil.IsHintOnlyCommand(runtimeCommand) {
|
||||
var userDefined []string
|
||||
for _, candidate := range shortcut.All() {
|
||||
if candidate.UserDefined {
|
||||
userDefined = append(userDefined, candidate.Service+" "+candidate.Command)
|
||||
}
|
||||
}
|
||||
t.Fatalf("runtime shortcut was not mounted as an executable command: command=%#v user_defined=%v config=%q", runtimeCommand, userDefined, os.Getenv("DWS_CONFIG_DIR"))
|
||||
}
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+members", "--sentinel", "fixture", "--mock", "--format", "json"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, engine, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +members" {
|
||||
t.Fatalf("runtime shortcut context = %#v", ctx)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("runtime shortcut execute: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
configDir := t.TempDir()
|
||||
shortcutDir := filepath.Join(configDir, "shortcuts")
|
||||
if err := os.MkdirAll(shortcutDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition := `version: 1
|
||||
service: chat
|
||||
command: "+members"
|
||||
product: chat
|
||||
description: runtime members fixture
|
||||
execute:
|
||||
tool: fixture_user_members
|
||||
bind:
|
||||
sentinel: "${sentinel}"
|
||||
flags:
|
||||
- name: sentinel
|
||||
type: string
|
||||
required: true
|
||||
desc: fixture sentinel
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(shortcutDir, "chat.members.yaml"), []byte(definition), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback$", "-test.count=1")
|
||||
command.Env = append(os.Environ(), runtimeShortcutFallbackConfigEnv+"="+configDir, runtimeShortcutFallbackChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("runtime shortcut child failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if !strings.Contains(string(output), `"_tool": "fixture_user_members"`) {
|
||||
t.Fatalf("runtime shortcut child used the wrong command:\n%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) {
|
||||
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalQueryAttempts) != 0 {
|
||||
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCommand string
|
||||
wantPreview paramAliasDryRunPreview
|
||||
}{
|
||||
{
|
||||
name: "group search query",
|
||||
args: []string{"chat", "+group-search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalQuery,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
|
||||
if executeErr != nil {
|
||||
t.Fatalf("fallback execute error = %v", executeErr)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("fallback crossed dry-run dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !reflect.DeepEqual(preview, test.wantPreview) {
|
||||
t.Fatalf("fallback preview = %#v, want canonical %#v", preview, test.wantPreview)
|
||||
}
|
||||
if ctx == nil || ctx.Command != test.wantCommand || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("fallback context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
correction := ctx.Corrections[0]
|
||||
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
|
||||
t.Fatalf("fallback correction = %#v", correction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOfficialCommandAliasesBypassFallbackAndReachEquivalentPayload(t *testing.T) {
|
||||
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalQueryAttempts) != 0 {
|
||||
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
|
||||
}
|
||||
_, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--keyword", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalKeywordAttempts) != 0 {
|
||||
t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err)
|
||||
}
|
||||
_, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(nativeCanonicalAttempts) != 0 {
|
||||
t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCommand string
|
||||
wantPreview paramAliasDryRunPreview
|
||||
}{
|
||||
{
|
||||
name: "search group shortcut alias",
|
||||
args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalKeyword,
|
||||
},
|
||||
{
|
||||
name: "chat group search shortcut alias",
|
||||
args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalQuery,
|
||||
},
|
||||
{
|
||||
name: "hidden native compatibility leaf",
|
||||
args: []string{"chat", "group", "search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat group search",
|
||||
wantPreview: nativeCanonical,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
|
||||
if executeErr != nil {
|
||||
t.Fatalf("official alias execute error = %v", executeErr)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("official alias crossed dry-run dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !reflect.DeepEqual(preview, test.wantPreview) {
|
||||
t.Fatalf("official alias preview = %#v, want canonical %#v", preview, test.wantPreview)
|
||||
}
|
||||
if ctx == nil || ctx.Command != test.wantCommand {
|
||||
t.Fatalf("official alias context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedReadFallbacksResolveCanonicalLeafBeforeParameterValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
target string
|
||||
}{
|
||||
{
|
||||
name: "members",
|
||||
args: []string{"chat", "+members", "--group", "Fixture Group"},
|
||||
target: "chat +group-members",
|
||||
},
|
||||
{
|
||||
name: "group member list",
|
||||
args: []string{"chat", "+group-member-list", "--group-name", "Fixture Group"},
|
||||
target: "chat +group-members",
|
||||
},
|
||||
{
|
||||
name: "list group bots",
|
||||
args: []string{"chat", "+list-group-bots", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "list robot",
|
||||
args: []string{"chat", "+list-robot", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "list robots",
|
||||
args: []string{"chat", "+list-robots", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "bot list",
|
||||
args: []string{"chat", "+bot-list", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "conversation detail",
|
||||
args: []string{"chat", "+conversation-detail", "--group", "cid-fixture"},
|
||||
target: "chat +conversation-info",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(test.args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws "+test.target || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("read fallback context = %#v", ctx)
|
||||
}
|
||||
wantPrefix := strings.Fields(test.target)
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("read fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOfficialGroupMembersAliasBypassesCommandFallback(t *testing.T) {
|
||||
args := []string{"chat", "+chat-group-members", "--conversation-id", "cid-fixture", "--member-types", "user"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-members-list" {
|
||||
t.Fatalf("official group-members alias context = %#v", ctx)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedRenameFallbackResolvesCanonicalLeafBeforeParameterValidation(t *testing.T) {
|
||||
args := []string{"chat", "+rename-group", "--id", "cid-fixture", "--name", "Fixture Group"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-update" || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("rename fallback context = %#v", ctx)
|
||||
}
|
||||
wantPrefix := []string{"chat", "+chat-update"}
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("rename fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedDocHistorySaveFallbacksPreserveArguments(t *testing.T) {
|
||||
sources := []string{"+create-version", "+save-version", "+snapshot", "+version-create"}
|
||||
for _, source := range sources {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
args := []string{"doc", source, "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws doc +history-save" || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("history-save fallback context = %#v", ctx)
|
||||
}
|
||||
wantArgs := []string{"doc", "+history-save", "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
if !reflect.DeepEqual(ctx.Args, wantArgs) {
|
||||
t.Fatalf("history-save fallback args = %v, want %v", ctx.Args, wantArgs)
|
||||
}
|
||||
correction := ctx.Corrections[0]
|
||||
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
|
||||
t.Fatalf("history-save correction = %#v", correction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocExportPDFRemainsUnknownShortcut(t *testing.T) {
|
||||
if entry, ok := cli.LookupCommandPathFallback("doc +export-pdf"); ok {
|
||||
t.Fatalf("+export-pdf must not receive a path-only fallback: %#v", entry)
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"doc", "+export-pdf", "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if ctx == nil {
|
||||
t.Fatal("+export-pdf returned nil context")
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) || structured.Reason != "unknown_shortcut" {
|
||||
t.Fatalf("+export-pdf preparse error = %T %#v", err, err)
|
||||
}
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("+export-pdf unexpectedly received corrections: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
candidates []string
|
||||
}{
|
||||
{path: "chat +group-send-text", candidates: []string{"chat +send-to-group", "chat +messages-send"}},
|
||||
{path: "chat +message-list", candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"}},
|
||||
{path: "chat +read-single", candidates: []string{"chat +messages-list-direct", "chat +chat-messages"}},
|
||||
{path: "chat +send", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-by-bot", candidates: []string{"chat +messages-send", "chat message send-by-bot"}},
|
||||
{path: "chat +send-dm", candidates: []string{"chat +dm", "chat +messages-send"}},
|
||||
{path: "chat +send-message", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-single", candidates: []string{"chat +dm", "chat +messages-send"}},
|
||||
{path: "chat +send-text", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-to", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-file", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +send-image", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +send-media", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
|
||||
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
|
||||
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
args := append(strings.Fields(test.path), "--format", "json")
|
||||
ctx, err := executeParamAliasE2E(t, caller, args...)
|
||||
if ctx == nil {
|
||||
t.Fatal("ambiguous command fallback returned nil context")
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) || structured.Reason != "ambiguous_command_fallback" || structured.ExitCode() != 3 {
|
||||
t.Fatalf("ambiguous error = %T %#v", err, err)
|
||||
}
|
||||
if len(structured.Actions) != len(test.candidates) || len(caller.calls) != 0 {
|
||||
t.Fatalf("ambiguous actions=%v calls=%#v", structured.Actions, caller.calls)
|
||||
}
|
||||
for _, candidate := range test.candidates {
|
||||
if !strings.Contains(structured.Hint, "dws "+candidate) {
|
||||
t.Errorf("ambiguous hint %q missing candidate %q", structured.Hint, candidate)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCanonicalShortcutBadFlagDoesNotEnterCommandFallback(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+chat-search", "--definitely-not-a-real-flag", "project"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("preparse error = %v", err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-search" {
|
||||
t.Fatalf("canonical context = %#v", ctx)
|
||||
}
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("canonical command received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("canonical bad flag error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing required query",
|
||||
args: []string{"chat", "+group-search", "--dry-run"},
|
||||
want: "请至少指定 --query、--keyword 之一",
|
||||
},
|
||||
{
|
||||
name: "unknown canonical flag",
|
||||
args: []string{"chat", "+group-search", "--definitely-not-a-real-flag", "project"},
|
||||
want: "unknown flag",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(test.args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if err != nil {
|
||||
t.Fatalf("preparse error = %v", err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-search" || len(ctx.Corrections) == 0 ||
|
||||
ctx.Corrections[0].Handler != "command-path-fallback" {
|
||||
t.Fatalf("rewritten context = %#v", ctx)
|
||||
}
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("canonical parameter error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
|
||||
invalidShortcuts := map[string]bool{
|
||||
"+bot-list": true,
|
||||
"+conversation-detail": true,
|
||||
"+conversation-category-list": true,
|
||||
"+conversation-group-list": true,
|
||||
"+list-my-groups": true,
|
||||
"+group-search": true,
|
||||
"+members": true,
|
||||
"+group-member-list": true,
|
||||
"+list-group-bots": true,
|
||||
"+list-robot": true,
|
||||
"+list-robots": true,
|
||||
"+message-list": true,
|
||||
"+read-single": true,
|
||||
"+rename-group": true,
|
||||
"+send": true,
|
||||
"+send-by-bot": true,
|
||||
"+send-dm": true,
|
||||
"+send-message": true,
|
||||
"+send-single": true,
|
||||
"+send-text": true,
|
||||
"+send-to": true,
|
||||
"+send-file": true,
|
||||
"+send-image": true,
|
||||
"+send-media": true,
|
||||
"+group-send-text": true,
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
chat := exactAppCommand(root, "chat")
|
||||
if chat == nil {
|
||||
t.Fatal("chat command missing")
|
||||
}
|
||||
for _, child := range chat.Commands() {
|
||||
if invalidShortcuts[child.Name()] {
|
||||
t.Fatalf("fallback name %q became a real command", child.Name())
|
||||
}
|
||||
for _, alias := range child.Aliases {
|
||||
if invalidShortcuts[alias] {
|
||||
t.Fatalf("fallback name %q became a Cobra alias", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
for path := range invalidShortcuts {
|
||||
if _, ok := cli.ResolveMeta("chat " + path); ok {
|
||||
t.Fatalf("fallback path %q leaked into embedded Schema", path)
|
||||
}
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "chat" && invalidShortcuts[declared.Command] {
|
||||
t.Fatalf("fallback name %q leaked into shortcut catalog", declared.Command)
|
||||
}
|
||||
}
|
||||
if _, ok := cli.ResolveMeta("oa +list-processes"); ok {
|
||||
t.Fatal("fallback path oa +list-processes leaked into embedded Schema")
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "oa" && declared.Command == "+list-processes" {
|
||||
t.Fatal("fallback name +list-processes leaked into shortcut catalog")
|
||||
}
|
||||
}
|
||||
|
||||
group := exactAppCommand(root, "chat group")
|
||||
searchCompatibility := exactAppCommand(root, "chat group search")
|
||||
if group == nil || searchCompatibility == nil || !searchCompatibility.Hidden || cmdutil.IsHintOnlyCommand(searchCompatibility) {
|
||||
t.Fatalf("chat group search must be a hidden executable compatibility leaf: group=%v search=%v", group, searchCompatibility)
|
||||
}
|
||||
var help bytes.Buffer
|
||||
group.SetOut(&help)
|
||||
if err := group.Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(help.String(), "\n search ") {
|
||||
t.Fatalf("hidden fallback source leaked into group help:\n%s", help.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
|
||||
invalidShortcuts := map[string]bool{
|
||||
"+create-version": true,
|
||||
"+save-version": true,
|
||||
"+snapshot": true,
|
||||
"+version-create": true,
|
||||
"+export-pdf": true,
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
doc := exactAppCommand(root, "doc")
|
||||
if doc == nil {
|
||||
t.Fatal("doc command missing")
|
||||
}
|
||||
for _, child := range doc.Commands() {
|
||||
if invalidShortcuts[child.Name()] {
|
||||
t.Fatalf("invalid shortcut %q became a real command", child.Name())
|
||||
}
|
||||
for _, alias := range child.Aliases {
|
||||
if invalidShortcuts[alias] {
|
||||
t.Fatalf("invalid shortcut %q became a Cobra alias", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
for path := range invalidShortcuts {
|
||||
if _, ok := cli.ResolveMeta("doc " + path); ok {
|
||||
t.Fatalf("invalid path %q leaked into embedded Schema", path)
|
||||
}
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "doc" && invalidShortcuts[declared.Command] {
|
||||
t.Fatalf("invalid shortcut %q leaked into shortcut catalog", declared.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoCommandPathFallbackCorrection(t *testing.T, ctx *pipeline.Context) {
|
||||
t.Helper()
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("official command unexpectedly received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exactAppCommand(root *cobra.Command, path string) *cobra.Command {
|
||||
current := root
|
||||
parts := strings.Fields(path)
|
||||
if len(parts) > 0 && parts[0] == root.Name() {
|
||||
parts = parts[1:]
|
||||
}
|
||||
for _, part := range parts {
|
||||
var next *cobra.Command
|
||||
for _, child := range current.Commands() {
|
||||
if child.Name() == part {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"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/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
@@ -227,115 +226,6 @@ func TestCrossPlatformCoverageDocDownloadPureCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
|
||||
if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
|
||||
t.Fatalf("array attempts = %#v %v", got, err)
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
|
||||
t.Fatal("malformed attempts succeeded")
|
||||
}
|
||||
if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
|
||||
t.Fatalf("legacy attempts = %#v %v", got, err)
|
||||
}
|
||||
if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
|
||||
t.Fatal("legacy attempts edge mismatch")
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"dws", "--debug", "doc", "get", "--node", "n"},
|
||||
{"dws", "--format=json", "doc", "--", "ignored"},
|
||||
{"dws", "--unknown", "value", "doc"},
|
||||
} {
|
||||
old := os.Args
|
||||
os.Args = args
|
||||
_ = currentCommandPath()
|
||||
os.Args = old
|
||||
}
|
||||
for _, inv := range []executor.Invocation{
|
||||
{LegacyPath: "legacy path"},
|
||||
{CanonicalProduct: "doc", Tool: "get"},
|
||||
{CanonicalProduct: "doc"},
|
||||
{},
|
||||
} {
|
||||
old := os.Args
|
||||
os.Args = []string{"dws"}
|
||||
_ = runtimeCommandPath(inv)
|
||||
os.Args = old
|
||||
}
|
||||
if cloneRecoveryArgs(nil) != nil {
|
||||
t.Fatal("empty recovery args should clone to nil")
|
||||
}
|
||||
original := map[string]any{"x": 1}
|
||||
clone := cloneRecoveryArgs(original)
|
||||
clone["x"] = 2
|
||||
if original["x"] != 1 {
|
||||
t.Fatal("recovery args were not cloned")
|
||||
}
|
||||
|
||||
if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("nil recovery search = %#v", got)
|
||||
}
|
||||
if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("blank recovery search = %#v", got)
|
||||
}
|
||||
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
|
||||
t.Fatal("nil recovery runtime call succeeded")
|
||||
}
|
||||
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("missing recovery endpoint error = %v", err)
|
||||
} else {
|
||||
var apiErr *apperrors.Error
|
||||
if !errors.As(err, &apiErr) || apiErr.Category != apperrors.CategoryAPI || apiErr.Operation != "discovery.resolve" || apiErr.Reason != "endpoint_not_resolved" {
|
||||
t.Fatalf("missing recovery endpoint classification = %#v", err)
|
||||
}
|
||||
}
|
||||
t.Setenv("DINGTALK_OK_MCP_URL", " https://catalog.test ")
|
||||
runtime := &recoveryRuntime{}
|
||||
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
|
||||
t.Fatalf("recovery endpoint override = %q %v", got, err)
|
||||
}
|
||||
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
|
||||
t.Fatal("recovery token mismatch")
|
||||
}
|
||||
if toRecoveryToolResponse(nil) != nil {
|
||||
t.Fatal("nil recovery response should stay nil")
|
||||
}
|
||||
response := toRecoveryToolResponse(&transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Type: "text", Text: "body"}}})
|
||||
if response == nil || !response.IsError || len(response.Content) != 1 {
|
||||
t.Fatalf("recovery response = %#v", response)
|
||||
}
|
||||
|
||||
items := []any{map[string]any{"title": "A", "url": "u", "desc": "d"}, "skip", map[string]any{}}
|
||||
for _, payload := range []map[string]any{
|
||||
nil,
|
||||
{"items": items},
|
||||
{"data": map[string]any{"items": items}},
|
||||
{"result": map[string]any{"items": items}},
|
||||
} {
|
||||
_ = parseDocSearchItemsFromMap(payload)
|
||||
}
|
||||
if toDocSearchItems("bad") != nil {
|
||||
t.Fatal("non-list doc items accepted")
|
||||
}
|
||||
result := &transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "{"}, {Text: `{"items":[{"title":"B"}]}`}}}
|
||||
if got := parseDocSearchItems(result); len(got) != 1 {
|
||||
t.Fatalf("block doc items = %#v", got)
|
||||
}
|
||||
if parseDocSearchItems(nil) != nil {
|
||||
t.Fatal("nil doc result should be nil")
|
||||
}
|
||||
searchItems := []recovery.DocSearchItem{{Title: "query", URL: "u"}, {Title: "other"}, {Title: "third"}, {Title: "fourth"}}
|
||||
if len(rerankDocSearchHits("query", recovery.RecoveryContext{ToolName: "tool", CommandPath: []string{"doc"}}, searchItems)) != 3 || rerankDocSearchHits("", recovery.RecoveryContext{}, nil) != nil {
|
||||
t.Fatal("doc search reranking mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
|
||||
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
|
||||
t.Cleanup(func() {
|
||||
@@ -511,59 +401,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
_ = defaultPATMCPEndpoint()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryLoadExecutionCoverage(t *testing.T) {
|
||||
if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing recovery execution succeeded")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRecoveryExecution(path); err == nil {
|
||||
t.Fatal("malformed recovery execution succeeded")
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
|
||||
"modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
|
||||
t.Fatalf("loaded execution = %#v %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
|
||||
var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
|
||||
}))
|
||||
defer server.Close()
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
|
||||
t.Cleanup(func() { SetDynamicServers(nil) })
|
||||
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
|
||||
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
|
||||
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
|
||||
t.Fatalf("recovery search = %#v %v", got, err)
|
||||
}
|
||||
result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
|
||||
t.Fatal("recovery MCP error succeeded")
|
||||
}
|
||||
server.Close()
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
|
||||
t.Fatal("recovery network error succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
@@ -760,7 +597,7 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
|
||||
child := &cobra.Command{Use: "child"}
|
||||
root.AddCommand(child)
|
||||
child.SetOut(io.Discard)
|
||||
if err := printCacheCompatNotice(child, "status"); err != nil {
|
||||
if err := printCacheCompatNotice(child, "dws cache status"); err != nil {
|
||||
t.Fatalf("cache %s: %v", format, err)
|
||||
}
|
||||
root.RemoveCommand(child)
|
||||
@@ -1747,9 +1584,6 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, jsonOut := range []bool{false, true} {
|
||||
if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
|
||||
t.Fatal("cache check failed")
|
||||
}
|
||||
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
|
||||
t.Fatalf("perf check = %#v", got)
|
||||
}
|
||||
@@ -2168,7 +2002,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
}
|
||||
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
|
||||
multi := filepath.Join(t.TempDir(), "multi")
|
||||
for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
|
||||
for _, name := range []string{"dingtalk-shared", "dingtalk-a", "dingtalk-b"} {
|
||||
dir := filepath.Join(multi, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2193,10 +2027,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
|
||||
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
|
||||
t.Fatalf("multi setup = %q, %v", output, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
|
||||
@@ -2220,7 +2054,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
|
||||
all := []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}
|
||||
for _, tc := range []struct {
|
||||
include []string
|
||||
exclude []string
|
||||
@@ -2238,7 +2072,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
|
||||
}
|
||||
}
|
||||
for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
|
||||
for _, selected := range [][]string{nil, {"dingtalk-shared"}, {"dingtalk-a"}} {
|
||||
_ = ensureMandatorySharedSkill(selected, all)
|
||||
}
|
||||
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
|
||||
|
||||
@@ -302,7 +302,6 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
||||
_ = newConfigCommand()
|
||||
_ = newCacheCommand()
|
||||
_ = newVersionCommand()
|
||||
_ = newRecoveryCommand(&GlobalFlags{})
|
||||
_ = newAPICommand(&GlobalFlags{})
|
||||
_ = NewRootCommand(context.Background())
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "环境健康检查",
|
||||
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
|
||||
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runDoctor,
|
||||
@@ -92,9 +92,6 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
||||
checks = append(checks, networkResult)
|
||||
|
||||
cacheResult := doctorCheckCache(w, jsonOut)
|
||||
checks = append(checks, cacheResult)
|
||||
|
||||
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
|
||||
checks = append(checks, versionResult)
|
||||
|
||||
@@ -297,24 +294,6 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Cache check ─────────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusPass,
|
||||
Message: "静态端点模式, 无需缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Version check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
|
||||
@@ -109,31 +109,6 @@ func TestPrintCheckResultNoHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmpty(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, false)
|
||||
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, true)
|
||||
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output in JSON mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
|
||||
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
|
||||
// contract asserts the exact phrase below inside the installed
|
||||
// dws-shared/SKILL.md, so removing it from the embedded skill source must
|
||||
// dingtalk-shared/SKILL.md, so removing it from the embedded skill source must
|
||||
// fail at PR time instead of at release time.
|
||||
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
@@ -19,12 +19,12 @@ func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dws-shared", "SKILL.md"))
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dingtalk-shared", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded dws-shared/SKILL.md: %v", err)
|
||||
t.Fatalf("read embedded dingtalk-shared/SKILL.md: %v", err)
|
||||
}
|
||||
const rule = "禁止选择第一项、最近登录或最近使用账号"
|
||||
if !strings.Contains(string(data), rule) {
|
||||
t.Fatalf("embedded dws-shared/SKILL.md lost the mandatory account safety rule %q", rule)
|
||||
t.Fatalf("embedded dingtalk-shared/SKILL.md lost the mandatory account safety rule %q", rule)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,43 +33,43 @@ func contains(ss []string, want string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// dws-shared must ship even when --skill narrows the set to a single product.
|
||||
// dingtalk-shared must ship even when --skill narrows the set to a single product.
|
||||
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
src := writeMultiSkillSrc(t, "dingtalk-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(all, "dws-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
|
||||
if !contains(all, "dingtalk-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dingtalk-shared: %v", all)
|
||||
}
|
||||
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(filtered, "dws-shared") {
|
||||
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
|
||||
if contains(filtered, "dingtalk-shared") {
|
||||
t.Fatalf("precondition: filter should drop dingtalk-shared for -s aitable: %v", filtered)
|
||||
}
|
||||
final := ensureMandatorySharedSkill(filtered, all)
|
||||
if !contains(final, "dws-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
|
||||
if !contains(final, "dingtalk-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dingtalk-shared: %v", final)
|
||||
}
|
||||
|
||||
// Actually install with the filtered+mandatory set and assert dws-shared landed.
|
||||
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-shared not installed with -s aitable: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-aitable not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the source has no dws-shared (older layout), nothing is forced.
|
||||
// When the source has no dingtalk-shared (older layout), nothing is forced.
|
||||
func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dingtalk-aitable")
|
||||
all, err := listMultiSkillNames(src)
|
||||
@@ -77,7 +77,7 @@ func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
|
||||
if contains(final, "dws-shared") {
|
||||
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
|
||||
if contains(final, "dingtalk-shared") {
|
||||
t.Fatalf("must not invent dingtalk-shared when source lacks it: %v", final)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,14 @@ func paramAliasResponseForTool(tool string) string {
|
||||
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
|
||||
case "search_groups":
|
||||
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
|
||||
case "search_contact_by_key_word":
|
||||
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "get_document_content":
|
||||
return `{"revision":1}`
|
||||
default:
|
||||
return `{}`
|
||||
}
|
||||
@@ -191,7 +199,7 @@ func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ..
|
||||
return ctx, root.Execute()
|
||||
}
|
||||
|
||||
func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
confirmation []string
|
||||
@@ -244,7 +252,7 @@ func TestBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
start := "2026-03-10T14:00:00+08:00"
|
||||
end := "2026-03-10T18:00:00+08:00"
|
||||
@@ -279,7 +287,7 @@ func TestParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
@@ -311,7 +319,7 @@ func TestParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command []string
|
||||
@@ -390,7 +398,7 @@ func TestChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
engine := newPipelineEngine()
|
||||
entries, err := cli.ReduceParamAliases(root)
|
||||
@@ -478,7 +486,7 @@ func TestAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
|
||||
}
|
||||
|
||||
func TestIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
command string
|
||||
want string
|
||||
@@ -531,7 +539,7 @@ func TestIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
func TestCrossPlatformCoverageHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
const command = "chat message list-direct"
|
||||
if _, ok := cli.LookupParamAlias(command); ok {
|
||||
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
|
||||
@@ -556,7 +564,7 @@ func TestHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
|
||||
func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
@@ -653,7 +661,7 @@ func TestSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
for _, args := range [][]string{
|
||||
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
|
||||
@@ -678,7 +686,7 @@ func TestParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -770,7 +778,7 @@ func TestAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
|
||||
func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
emitted string
|
||||
@@ -817,7 +825,7 @@ func TestRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
|
||||
func TestCrossPlatformCoverageFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
|
||||
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
|
||||
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
|
||||
if err != want {
|
||||
|
||||
@@ -44,7 +44,7 @@ import (
|
||||
// - expect=<realFlag> : emitted must reduce to that canonical flag.
|
||||
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
|
||||
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
|
||||
func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
@@ -113,13 +113,19 @@ func TestParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
|
||||
if len(flagArgs) < 2 || flagArgs[1] != fixtureValue {
|
||||
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
if len(flagArgs) == 0 {
|
||||
t.Fatalf("%q on %q lost its flag and value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
}
|
||||
got := flagArgs[0]
|
||||
gotBare := strings.SplitN(strings.TrimPrefix(got, "--"), "=", 2)[0]
|
||||
gotBare, inlineValue, hasInlineValue := strings.Cut(strings.TrimPrefix(got, "--"), "=")
|
||||
switch {
|
||||
case got == "--"+c.Expect:
|
||||
case hasInlineValue && inlineValue != fixtureValue:
|
||||
t.Fatalf("%q on %q changed its inline value: got %q, want %q (args=%v)", c.Emitted, c.Command, inlineValue, fixtureValue, ctx.Args)
|
||||
case !hasInlineValue && (len(flagArgs) < 2 || flagArgs[1] != fixtureValue):
|
||||
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
}
|
||||
switch {
|
||||
case gotBare == c.Expect:
|
||||
// (1) rewritten; the embedded table must agree.
|
||||
if !hasEntry {
|
||||
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
|
||||
|
||||
@@ -30,13 +30,34 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
|
||||
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
|
||||
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
|
||||
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
|
||||
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
|
||||
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
|
||||
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
|
||||
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
|
||||
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
|
||||
"chat +conversation-set-top": {"chat", "+conversation-set-top", "--conversation-id", "fixture-conversation", "--yes"},
|
||||
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
|
||||
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--size", "7"},
|
||||
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
|
||||
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
|
||||
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
|
||||
@@ -81,9 +102,36 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
|
||||
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
|
||||
"doc +access-grant": {"doc", "+access-grant", "--node", "node-1", "--to", "Fixture User", "--role", "READER", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
|
||||
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
|
||||
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
|
||||
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
|
||||
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
|
||||
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
|
||||
"doc +history-revert": {"doc", "+history-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +inspect": {"doc", "+inspect", "--node", "node-1", "--include-history"},
|
||||
"doc +list": {"doc", "+list", "--folder", "folder-1", "--cursor", "cursor-1"},
|
||||
"doc +move": {"doc", "+move", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"doc +search": {"doc", "+search", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +template-list": {"doc", "+template-list", "--source", "MY", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
|
||||
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "append", "--content", "fixture body", "--expected-revision", "1", "--yes"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
|
||||
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
|
||||
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
|
||||
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
|
||||
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
|
||||
@@ -101,6 +149,13 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"doc +copy": {
|
||||
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
},
|
||||
"doc block insert": {
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
|
||||
},
|
||||
"chat message list": {
|
||||
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
},
|
||||
@@ -111,6 +166,9 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
"chat +conversation-set-top": {
|
||||
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
|
||||
},
|
||||
}
|
||||
|
||||
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
|
||||
@@ -149,6 +207,38 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat message send", emitted: "file", canonical: "file-path"},
|
||||
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
|
||||
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "conversation-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "title", canonical: "name"},
|
||||
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
|
||||
{command: "chat +flag-list", emitted: "limit", canonical: "size"},
|
||||
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
|
||||
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
|
||||
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
|
||||
{command: "chat +conversation-set-top", emitted: "chat-ids", canonical: "conversation-ids"},
|
||||
{command: "chat +chat-members-get", emitted: "conversation-id", canonical: "id"},
|
||||
{command: "chat +chat-members-get", emitted: "open-dingtalk-ids", canonical: "users"},
|
||||
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
|
||||
{command: "chat +messages-list", emitted: "start", canonical: "time"},
|
||||
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
|
||||
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +chat-add-bot", emitted: "conversation-id", canonical: "id"},
|
||||
{command: "chat +chat-add-bot", emitted: "robot", canonical: "robot-code"},
|
||||
{command: "chat +chat-audit-join", emitted: "applicant-user-id", canonical: "applicant"},
|
||||
{command: "chat +chat-mute-member", emitted: "user-ids", canonical: "users"},
|
||||
{command: "chat +chat-remove-bot", emitted: "open-bot-id", canonical: "bot-id"},
|
||||
{command: "chat +chat-role-remove-user", emitted: "open-dingtalk-id", canonical: "user"},
|
||||
{command: "chat +chat-transfer-owner", emitted: "user-id", canonical: "new-owner"},
|
||||
{command: "chat +feed-group-query-item", emitted: "chat-ids", canonical: "conversation-ids"},
|
||||
{command: "chat +messages-combine-forward", emitted: "src-open-cid", canonical: "src-conversation-id"},
|
||||
{command: "chat +messages-forward", emitted: "source-message-id", canonical: "msg-id"},
|
||||
{command: "chat +messages-forward-topic", emitted: "src-open-message-id", canonical: "src-msg-id"},
|
||||
{command: "chat +messages-resource-download", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
@@ -163,14 +253,33 @@ var paramAliasNewIMCases = []struct {
|
||||
// That duplicated command construction was enough to push the pre-existing
|
||||
// macOS app suite beyond its package-level 10-minute timeout.
|
||||
var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
|
||||
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
|
||||
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "parent-folder-id"): true, // template target folder identifier
|
||||
paramAliasPayloadCaseKey("doc +export-submit", "doc-id"): true, // Doc node identifier alias
|
||||
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
|
||||
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
|
||||
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
|
||||
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
|
||||
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
|
||||
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
|
||||
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
|
||||
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
|
||||
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
|
||||
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
|
||||
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
|
||||
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
@@ -273,8 +382,9 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
if _, err := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...); err != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", err, canonicalArgs, canonicalCaller.calls)
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
@@ -290,13 +400,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
activeAliases++
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", err, aliasArgs, aliasCaller.calls)
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
|
||||
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
@@ -308,6 +421,16 @@ func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayl
|
||||
}
|
||||
}
|
||||
|
||||
// Resource download deliberately continues from the transport call into a
|
||||
// local HTTPS download. The generic capture caller returns an empty object, so
|
||||
// this command's stable post-transport validation error is the expected test
|
||||
// boundary; canonical and alias calls must still produce the same request and
|
||||
// the same error.
|
||||
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
|
||||
return command == "chat +messages-resource-download" && err != nil &&
|
||||
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
}
|
||||
|
||||
// +chat-messages supplies the current wall-clock time when callers omit
|
||||
// --time. Alias equivalence concerns the resolved target and transport shape;
|
||||
// a suite crossing a second boundary must not make that default appear
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -55,7 +55,7 @@ func TestLeadingPersistentFlagVariantsReachTheRealCommand(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
args := []string{
|
||||
"chat", "message", "send",
|
||||
@@ -98,3 +98,65 @@ func TestPreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
t.Fatalf("--debug details missing from early error:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTree(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantReason string
|
||||
wantCommand string
|
||||
}{
|
||||
{
|
||||
name: "unknown shortcut",
|
||||
args: []string{"chat", "+chat-mesages", "--keyword", "x", "--format", "json"},
|
||||
wantReason: "unknown_shortcut",
|
||||
wantCommand: "dws chat",
|
||||
},
|
||||
{
|
||||
name: "unknown dev app subcommand",
|
||||
args: []string{"dev", "app", "search", "--keyword", "x", "--format", "json"},
|
||||
wantReason: "unknown_subcommand",
|
||||
wantCommand: "dws dev app",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if ctx == nil || ctx.Command != test.wantCommand {
|
||||
t.Fatalf("Context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
err = newPreParseValidationError(err)
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
|
||||
t.Fatalf("structured error = %#v", structured)
|
||||
}
|
||||
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
|
||||
t.Fatalf("command error leaked flag classification: %#v", structured)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolvedShortcutStillUsesExactLeafFlagError(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+chat-messages", "--keyword", "x", "--format", "json"}
|
||||
if ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args); err != nil {
|
||||
t.Fatalf("valid shortcut path failed PreParse: %#v, %v", ctx, err)
|
||||
}
|
||||
root.SetArgs(args)
|
||||
err := root.Execute()
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
if structured.Reason != "unknown_flag" || !strings.Contains(structured.Message, "dws chat +chat-messages --help") {
|
||||
t.Fatalf("resolved shortcut flag error = %#v", structured)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,496 +1,118 @@
|
||||
// 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"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
recoverySavePlan = (*recovery.Store).SavePlan
|
||||
recoverySaveAnalysis = (*recovery.Store).SaveAnalysis
|
||||
)
|
||||
const recoveryUnsupportedMessage = "dws recovery 不再支持:失败快照恢复计划/执行/闭环已下线,请改用 doctor / schema / 对应业务命令排查。"
|
||||
|
||||
func newRecoveryCommand(flags *GlobalFlags) *cobra.Command {
|
||||
var (
|
||||
planUseLast bool
|
||||
planEventID string
|
||||
executeUseLast bool
|
||||
executeEventID string
|
||||
finalEventID string
|
||||
finalOutcome string
|
||||
executionFile string
|
||||
)
|
||||
|
||||
runtime := newRecoveryRuntime(flags)
|
||||
type recoveryCompatNotice struct {
|
||||
Status string `json:"status"`
|
||||
Command string `json:"command"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// newRecoveryCommand keeps a visible Deprecated compatibility surface for
|
||||
// historical argv and Interface Integrity. Behavior is unchanged: every leaf
|
||||
// returns an explicit unsupported notice. Skills must not teach this path.
|
||||
func newRecoveryCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "recovery",
|
||||
Short: "错误恢复辅助命令",
|
||||
Long: "读取失败快照,生成恢复分析,并回写恢复结果。",
|
||||
Short: "不再支持:错误恢复辅助命令(兼容入口)",
|
||||
Long: "此命令组仅为历史 argv 兼容保留,不再读取失败快照或生成恢复计划。Skill / Agent 请勿引导此路径。",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
return printRecoveryUnsupported(cmd, "dws recovery")
|
||||
},
|
||||
}
|
||||
|
||||
planCmd := &cobra.Command{
|
||||
Use: "plan",
|
||||
Short: "基于失败快照生成恢复计划",
|
||||
Short: "不再支持:基于失败快照生成恢复计划",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
last, err := loadRecoverySnapshot(store, planUseLast, planEventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
planner := recovery.NewPlanner(runtime)
|
||||
plan := planner.PlanWithOptions(cmd.Context(), last.Context, recovery.PlanOptions{
|
||||
EventID: last.EventID,
|
||||
EnableDocSearch: true,
|
||||
})
|
||||
recovery.HydratePlanForEvent(last.EventID, last.Context, last.Replay, &plan)
|
||||
if err := recoverySavePlan(store, last.EventID, plan); err != nil {
|
||||
return fmt.Errorf("保存恢复计划失败: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"event_id": last.EventID,
|
||||
"context": last.Context,
|
||||
"plan": plan,
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery plan")
|
||||
},
|
||||
}
|
||||
planCmd.Flags().BoolVar(&planUseLast, "last", false, "读取最近一次失败快照")
|
||||
planCmd.Flags().StringVar(&planEventID, "event-id", "", "按 event_id 读取失败快照")
|
||||
planCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
|
||||
planCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
executeCmd := &cobra.Command{
|
||||
Use: "execute",
|
||||
Short: "生成面向 Agent 的恢复分析包",
|
||||
Short: "不再支持:生成面向 Agent 的恢复分析包",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
last, err := loadRecoverySnapshot(store, executeUseLast, executeEventID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
planner := recovery.NewPlanner(runtime)
|
||||
executor := recovery.NewExecutor(planner, runtime)
|
||||
bundle := executor.Execute(cmd.Context(), *last)
|
||||
if err := recoverySaveAnalysis(store, last.EventID, bundle.Plan, bundle); err != nil {
|
||||
return fmt.Errorf("保存恢复分析失败: %w", err)
|
||||
}
|
||||
|
||||
return output.WriteCommandPayload(cmd, bundle, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery execute")
|
||||
},
|
||||
}
|
||||
executeCmd.Flags().BoolVar(&executeUseLast, "last", false, "读取最近一次失败快照")
|
||||
executeCmd.Flags().StringVar(&executeEventID, "event-id", "", "按 event_id 读取失败快照")
|
||||
executeCmd.Flags().Bool("last", false, "旧版兼容参数;recovery 不再支持")
|
||||
executeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
finalizeCmd := &cobra.Command{
|
||||
Use: "finalize",
|
||||
Short: "回写恢复闭环结果",
|
||||
Short: "不再支持:回写恢复闭环结果",
|
||||
Deprecated: "不再支持;" + recoveryUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if strings.TrimSpace(finalEventID) == "" {
|
||||
return fmt.Errorf("必须提供 --event-id")
|
||||
}
|
||||
if strings.TrimSpace(finalOutcome) == "" {
|
||||
return fmt.Errorf("必须提供 --outcome")
|
||||
}
|
||||
switch finalOutcome {
|
||||
case "recovered", "failed", "handoff":
|
||||
default:
|
||||
return fmt.Errorf("--outcome 仅支持 recovered|failed|handoff")
|
||||
}
|
||||
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
var execution *recovery.RecoveryExecution
|
||||
if strings.TrimSpace(executionFile) != "" {
|
||||
loaded, err := loadRecoveryExecution(executionFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
execution = &loaded
|
||||
}
|
||||
if err := store.Finalize(finalEventID, finalOutcome, execution); err != nil {
|
||||
return fmt.Errorf("回写恢复结果失败: %w", err)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"event_id": finalEventID,
|
||||
"outcome": finalOutcome,
|
||||
"success": true,
|
||||
}
|
||||
if execution != nil {
|
||||
payload["execution_recorded"] = true
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
return printRecoveryUnsupported(cmd, "dws recovery finalize")
|
||||
},
|
||||
}
|
||||
finalizeCmd.Flags().StringVar(&finalEventID, "event-id", "", "恢复事件 ID")
|
||||
finalizeCmd.Flags().StringVar(&finalOutcome, "outcome", "", "恢复结果: recovered|failed|handoff")
|
||||
finalizeCmd.Flags().StringVar(&executionFile, "execution-file", "", "Agent 执行详情 JSON 文件")
|
||||
finalizeCmd.Flags().String("event-id", "", "旧版兼容参数;recovery 不再支持")
|
||||
finalizeCmd.Flags().String("outcome", "", "旧版兼容参数;recovery 不再支持")
|
||||
finalizeCmd.Flags().String("execution-file", "", "旧版兼容参数;recovery 不再支持")
|
||||
|
||||
cmd.AddCommand(planCmd, executeCmd, finalizeCmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func loadRecoverySnapshot(store *recovery.Store, useLast bool, eventID string) (*recovery.LastError, error) {
|
||||
if useLast && strings.TrimSpace(eventID) != "" {
|
||||
return nil, fmt.Errorf("--last 和 --event-id 不能同时使用")
|
||||
func printRecoveryUnsupported(cmd *cobra.Command, command string) error {
|
||||
notice := recoveryCompatNotice{
|
||||
Status: "unsupported",
|
||||
Command: command,
|
||||
Message: recoveryUnsupportedMessage,
|
||||
}
|
||||
switch {
|
||||
case useLast:
|
||||
last, err := store.LoadLastError()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取失败快照失败: %w", err)
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "", "json":
|
||||
if err := json.NewEncoder(cmd.OutOrStdout()).Encode(notice); err != nil {
|
||||
return err
|
||||
}
|
||||
return last, nil
|
||||
case strings.TrimSpace(eventID) != "":
|
||||
last, err := store.LoadErrorByEvent(strings.TrimSpace(eventID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取失败快照失败: %w", err)
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
case "pretty":
|
||||
data, _ := json.MarshalIndent(notice, "", " ")
|
||||
if _, err := fmt.Fprintln(cmd.OutOrStdout(), string(data)); err != nil {
|
||||
return err
|
||||
}
|
||||
return last, nil
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
default:
|
||||
return nil, fmt.Errorf("必须通过 --last 或 --event-id 指定失败快照")
|
||||
}
|
||||
}
|
||||
|
||||
func loadRecoveryExecution(path string) (recovery.RecoveryExecution, error) {
|
||||
var execution recovery.RecoveryExecution
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return execution, fmt.Errorf("读取恢复执行详情失败: %w", err)
|
||||
}
|
||||
var payload recoveryExecutionPayload
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
|
||||
}
|
||||
execution.Actions = append([]string(nil), payload.Actions...)
|
||||
if len(execution.Actions) == 0 && strings.TrimSpace(payload.Action) != "" {
|
||||
execution.Actions = []string{strings.TrimSpace(payload.Action)}
|
||||
}
|
||||
execution.Result = strings.TrimSpace(payload.Result)
|
||||
execution.ErrorSummary = strings.TrimSpace(payload.ErrorSummary)
|
||||
if execution.ErrorSummary == "" {
|
||||
execution.ErrorSummary = strings.TrimSpace(payload.Error)
|
||||
}
|
||||
|
||||
attempts, err := decodeRecoveryAttempts(payload.Attempts, execution.Actions, execution.Result, execution.ErrorSummary)
|
||||
if err != nil {
|
||||
return execution, fmt.Errorf("解析恢复执行详情失败: %w", err)
|
||||
}
|
||||
if len(attempts) == 0 && payload.Attempt > 0 {
|
||||
attempts = legacyRecoveryAttempts(payload.Attempt, execution.Actions, execution.Result, execution.ErrorSummary)
|
||||
}
|
||||
execution.Attempts = attempts
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
type recoveryExecutionPayload struct {
|
||||
Action string `json:"action,omitempty"`
|
||||
Actions []string `json:"actions,omitempty"`
|
||||
Attempt int `json:"attempt,omitempty"`
|
||||
Attempts json.RawMessage `json:"attempts,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ErrorSummary string `json:"error_summary,omitempty"`
|
||||
}
|
||||
|
||||
func decodeRecoveryAttempts(raw json.RawMessage, actions []string, result, errorSummary string) ([]recovery.RecoveryAttempt, error) {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
var attempts []recovery.RecoveryAttempt
|
||||
if err := json.Unmarshal(raw, &attempts); err != nil {
|
||||
return nil, err
|
||||
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", notice.Command, notice.Message); err != nil {
|
||||
return err
|
||||
}
|
||||
return attempts, nil
|
||||
}
|
||||
|
||||
var count int
|
||||
if err := json.Unmarshal(raw, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return legacyRecoveryAttempts(count, actions, result, errorSummary), nil
|
||||
}
|
||||
|
||||
func legacyRecoveryAttempts(count int, actions []string, result, errorSummary string) []recovery.RecoveryAttempt {
|
||||
if count <= 0 {
|
||||
return nil
|
||||
}
|
||||
summary := strings.TrimSpace(strings.Join(actions, ", "))
|
||||
if summary == "" {
|
||||
summary = "legacy execution attempt"
|
||||
}
|
||||
attempts := make([]recovery.RecoveryAttempt, 0, count)
|
||||
for i := 0; i < count; i++ {
|
||||
attempts = append(attempts, recovery.RecoveryAttempt{
|
||||
CommandSummary: summary,
|
||||
Result: result,
|
||||
ErrorSummary: errorSummary,
|
||||
Source: "legacy_execution_file",
|
||||
})
|
||||
}
|
||||
return attempts
|
||||
}
|
||||
|
||||
type recoveryRuntime struct {
|
||||
transport *transport.Client
|
||||
flags *GlobalFlags
|
||||
}
|
||||
|
||||
func newRecoveryRuntime(flags *GlobalFlags) *recoveryRuntime {
|
||||
var httpClient *http.Client
|
||||
if flags != nil && flags.Timeout > 0 {
|
||||
httpClient = &http.Client{Timeout: time.Duration(flags.Timeout) * time.Second}
|
||||
}
|
||||
client := transport.NewClient(httpClient)
|
||||
client.ExtraHeaders = resolveIdentityHeaders()
|
||||
return &recoveryRuntime{
|
||||
transport: client,
|
||||
flags: flags,
|
||||
return apperrors.NewValidation(recoveryUnsupportedMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.RecoveryContext) (recovery.KnowledgeRetrieval, error) {
|
||||
const (
|
||||
searchPage = 1
|
||||
searchSize = 5
|
||||
)
|
||||
requestArgs := map[string]any{
|
||||
"keyword": query,
|
||||
"page": searchPage,
|
||||
"size": searchSize,
|
||||
}
|
||||
|
||||
retrieval := recovery.KnowledgeRetrieval{
|
||||
DocSearch: recovery.DocSearch{
|
||||
Provider: "open_platform_docs",
|
||||
Query: query,
|
||||
Page: searchPage,
|
||||
Size: searchSize,
|
||||
Status: "empty",
|
||||
Request: &recovery.ToolCallRecord{
|
||||
ServerID: "devdoc",
|
||||
ToolName: "search_open_platform_docs_rag",
|
||||
Arguments: cloneRecoveryArgs(requestArgs),
|
||||
},
|
||||
},
|
||||
}
|
||||
if r == nil || strings.TrimSpace(query) == "" {
|
||||
retrieval.DocSearch.Status = "skipped"
|
||||
return retrieval, nil
|
||||
}
|
||||
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
|
||||
if result != nil {
|
||||
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
|
||||
}
|
||||
if err != nil {
|
||||
retrieval.DocSearch.Status = "error"
|
||||
retrieval.DocSearch.Error = err.Error()
|
||||
return retrieval, err
|
||||
}
|
||||
|
||||
retrieval.DocSearch.Items = parseDocSearchItems(result)
|
||||
if len(retrieval.DocSearch.Items) > 0 {
|
||||
retrieval.DocSearch.Status = "success"
|
||||
retrieval.KBHits = rerankDocSearchHits(query, rc, retrieval.DocSearch.Items)
|
||||
}
|
||||
return retrieval, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName string, args map[string]any) (*transport.ToolCallResult, error) {
|
||||
if r == nil || r.transport == nil {
|
||||
return nil, fmt.Errorf("recovery runtime not initialized")
|
||||
}
|
||||
endpoint, err := r.resolveEndpoint(ctx, serverID, toolName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
authToken, err := resolveRuntimeAuthToken(ctx, recoveryRuntimeToken(r.flags))
|
||||
if err != nil {
|
||||
return nil, tokenResolutionError(err)
|
||||
}
|
||||
tc := r.transport.WithAuth(authToken, resolveIdentityHeaders())
|
||||
result, err := tc.CallTool(ctx, endpoint, toolName, args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.IsError {
|
||||
return &result, apperrors.NewAPI(
|
||||
extractMCPErrorMessage(result),
|
||||
apperrors.WithOperation("tools/call"),
|
||||
apperrors.WithReason("mcp_tool_error"),
|
||||
apperrors.WithServerKey(serverID),
|
||||
)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *recoveryRuntime) resolveEndpoint(_ context.Context, productID, toolName string) (string, error) {
|
||||
if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok {
|
||||
return endpoint, nil
|
||||
}
|
||||
return "", endpointNotResolvedError(productID, toolName, "no dynamic endpoint registered for product or tool")
|
||||
}
|
||||
|
||||
func recoveryRuntimeToken(flags *GlobalFlags) string {
|
||||
if flags == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(flags.Token)
|
||||
}
|
||||
|
||||
func toRecoveryToolResponse(result *transport.ToolCallResult) *recovery.ToolResponse {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
response := &recovery.ToolResponse{IsError: result.IsError}
|
||||
if len(result.Blocks) > 0 {
|
||||
response.Content = make([]recovery.ToolResponseBlock, 0, len(result.Blocks))
|
||||
for _, block := range result.Blocks {
|
||||
response.Content = append(response.Content, recovery.ToolResponseBlock{
|
||||
Type: block.Type,
|
||||
Text: block.Text,
|
||||
})
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
func parseDocSearchItems(result *transport.ToolCallResult) []recovery.DocSearchItem {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if items := parseDocSearchItemsFromMap(result.Content); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
for _, block := range result.Blocks {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(block.Text), &payload); err == nil {
|
||||
if items := parseDocSearchItemsFromMap(payload); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseDocSearchItemsFromMap(payload map[string]any) []recovery.DocSearchItem {
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
if items := toDocSearchItems(payload["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
if data, ok := payload["data"].(map[string]any); ok {
|
||||
if items := toDocSearchItems(data["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
if result, ok := payload["result"].(map[string]any); ok {
|
||||
if items := toDocSearchItems(result["items"]); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func toDocSearchItems(raw any) []recovery.DocSearchItem {
|
||||
list, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
items := make([]recovery.DocSearchItem, 0, len(list))
|
||||
for _, entry := range list {
|
||||
object, ok := entry.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := recovery.DocSearchItem{}
|
||||
if title, ok := object["title"].(string); ok {
|
||||
item.Title = title
|
||||
}
|
||||
if url, ok := object["url"].(string); ok {
|
||||
item.URL = url
|
||||
}
|
||||
if desc, ok := object["desc"].(string); ok {
|
||||
item.Desc = desc
|
||||
}
|
||||
if item.Title != "" || item.URL != "" || item.Desc != "" {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func rerankDocSearchHits(query string, rc recovery.RecoveryContext, items []recovery.DocSearchItem) []recovery.KBHit {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
keywords := strings.Fields(strings.ToLower(strings.TrimSpace(query)))
|
||||
type scoredHit struct {
|
||||
hit recovery.KBHit
|
||||
score float64
|
||||
}
|
||||
scored := make([]scoredHit, 0, len(items))
|
||||
for _, item := range items {
|
||||
text := strings.ToLower(strings.Join(append([]string{
|
||||
item.Title,
|
||||
item.URL,
|
||||
item.Desc,
|
||||
rc.ToolName,
|
||||
}, rc.CommandPath...), " "))
|
||||
score := 0.0
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
score += 1
|
||||
}
|
||||
}
|
||||
scored = append(scored, scoredHit{
|
||||
hit: recovery.KBHit{
|
||||
Source: "open_platform_docs",
|
||||
Title: item.Title,
|
||||
URL: item.URL,
|
||||
Snippet: item.Desc,
|
||||
Score: score,
|
||||
},
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(scored, func(i, j int) bool {
|
||||
return scored[i].score > scored[j].score
|
||||
})
|
||||
limit := len(scored)
|
||||
if limit > 3 {
|
||||
limit = 3
|
||||
}
|
||||
hits := make([]recovery.KBHit, 0, limit)
|
||||
for _, item := range scored[:limit] {
|
||||
hits = append(hits, item.hit)
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryDeprecatedUnsupportedShim(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
group := mustFindCommand(t, root, "recovery")
|
||||
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
|
||||
t.Fatalf("recovery group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
|
||||
}
|
||||
|
||||
for _, leaf := range []string{"plan", "execute", "finalize"} {
|
||||
cmd := mustFindCommand(t, root, "recovery", leaf)
|
||||
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
|
||||
t.Fatalf("recovery %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
|
||||
}
|
||||
wantFlags := []string{"event-id"}
|
||||
switch leaf {
|
||||
case "plan", "execute":
|
||||
wantFlags = append(wantFlags, "last")
|
||||
case "finalize":
|
||||
wantFlags = append(wantFlags, "outcome", "execution-file")
|
||||
}
|
||||
for _, flag := range wantFlags {
|
||||
if cmd.Flags().Lookup(flag) == nil {
|
||||
t.Fatalf("recovery %s missing --%s", leaf, flag)
|
||||
}
|
||||
}
|
||||
for _, child := range newRecoveryCommand().Commands() {
|
||||
if child.Name() != leaf {
|
||||
continue
|
||||
}
|
||||
if err := child.RunE(child, nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
|
||||
t.Fatalf("recovery %s RunE = %v, want 不再支持", leaf, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"", "json", "pretty", "table"} {
|
||||
var out bytes.Buffer
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.PersistentFlags().String("format", format, "")
|
||||
cmd.SetOut(&out)
|
||||
sub := &cobra.Command{Use: "recovery"}
|
||||
cmd.AddCommand(sub)
|
||||
err := printRecoveryUnsupported(sub, "dws recovery plan")
|
||||
if err == nil {
|
||||
t.Fatalf("format=%q returned nil error", format)
|
||||
}
|
||||
typed, ok := err.(*apperrors.Error)
|
||||
if !ok || typed.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("format=%q error = %T/%v, want validation Error", format, err, err)
|
||||
}
|
||||
got := out.String() + err.Error()
|
||||
if !strings.Contains(got, "不再支持") {
|
||||
t.Fatalf("format=%q missing 不再支持:\n%s", format, got)
|
||||
}
|
||||
if format == "" || format == "json" || format == "pretty" {
|
||||
if !strings.Contains(got, `"status":"unsupported"`) && !strings.Contains(got, `"status": "unsupported"`) {
|
||||
t.Fatalf("format=%q missing unsupported JSON status:\n%s", format, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
cmd := &cobra.Command{Use: "dws"}
|
||||
cmd.PersistentFlags().String("format", format, "")
|
||||
cmd.SetOut(failWriter{})
|
||||
sub := &cobra.Command{Use: "recovery"}
|
||||
cmd.AddCommand(sub)
|
||||
if err := printRecoveryUnsupported(sub, "dws recovery plan"); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := newRecoveryCommand().RunE(newRecoveryCommand(), nil); err == nil || !strings.Contains(err.Error(), "不再支持") {
|
||||
t.Fatalf("recovery parent RunE = %v, want 不再支持", err)
|
||||
}
|
||||
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||
}
|
||||
|
||||
type failWriter struct{}
|
||||
|
||||
func (failWriter) Write([]byte) (int, error) {
|
||||
return 0, errWriteFailed
|
||||
}
|
||||
|
||||
var errWriteFailed = errors.New("write failed")
|
||||
@@ -1,151 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
func recoveryCoverageRun(cmdArgs ...string) (string, error) {
|
||||
cmd := newRecoveryCommand(&GlobalFlags{})
|
||||
out := &strings.Builder{}
|
||||
cmd.SetOut(out)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(cmdArgs)
|
||||
err := cmd.Execute()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryCommandRemainingCoverage(t *testing.T) {
|
||||
oldSavePlan, oldSaveAnalysis := recoverySavePlan, recoverySaveAnalysis
|
||||
t.Cleanup(func() {
|
||||
recoverySavePlan, recoverySaveAnalysis = oldSavePlan, oldSaveAnalysis
|
||||
})
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
store := recovery.NewStore(configDir)
|
||||
last, err := store.Capture(recovery.RecoveryContext{ServerID: "doc", ToolName: "get"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recoverySavePlan = func(*recovery.Store, string, recovery.RecoveryPlan) error { return errors.New("save plan") }
|
||||
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||
t.Fatal("injected plan save failure succeeded")
|
||||
}
|
||||
recoverySavePlan = oldSavePlan
|
||||
recoverySaveAnalysis = func(*recovery.Store, string, recovery.RecoveryPlan, recovery.RecoveryBundle) error {
|
||||
return errors.New("save analysis")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||
t.Fatal("injected analysis save failure succeeded")
|
||||
}
|
||||
recoverySaveAnalysis = oldSaveAnalysis
|
||||
|
||||
parent := newRecoveryCommand(nil)
|
||||
parent.SetOut(io.Discard)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("plan", "--last"); err != nil || !strings.Contains(out, last.EventID) {
|
||||
t.Fatalf("recovery plan = %q, %v", out, err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("execute", "--event-id", last.EventID); err != nil || out == "" {
|
||||
t.Fatalf("recovery execute = %q, %v", out, err)
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"finalize"},
|
||||
{"finalize", "--event-id", last.EventID},
|
||||
{"finalize", "--event-id", last.EventID, "--outcome", "unknown"},
|
||||
{"finalize", "--event-id", last.EventID, "--outcome", "recovered", "--execution-file", "missing"},
|
||||
} {
|
||||
if _, err := recoveryCoverageRun(args...); err == nil {
|
||||
t.Fatalf("recovery finalize %#v should fail", args)
|
||||
}
|
||||
}
|
||||
executionPath := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(executionPath, []byte(`{"action":"retry","attempt":1,"result":"ok"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "handoff", "--execution-file", executionPath); err != nil || !strings.Contains(out, "execution_recorded") {
|
||||
t.Fatalf("recovery finalize = %q, %v", out, err)
|
||||
}
|
||||
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := loadRecoverySnapshot(store, true, last.EventID); err == nil {
|
||||
t.Fatal("conflicting snapshot selectors should fail")
|
||||
}
|
||||
if _, err := loadRecoverySnapshot(store, false, "missing"); err == nil {
|
||||
t.Fatal("missing event snapshot should fail")
|
||||
}
|
||||
if _, err := loadRecoverySnapshot(store, false, ""); err == nil {
|
||||
t.Fatal("empty snapshot selector should fail")
|
||||
}
|
||||
missingStore := recovery.NewStore(t.TempDir())
|
||||
if _, err := loadRecoverySnapshot(missingStore, true, ""); err == nil {
|
||||
t.Fatal("missing latest snapshot should fail")
|
||||
}
|
||||
|
||||
eventsPath := filepath.Join(configDir, "recovery", "recovery_events.jsonl")
|
||||
if err := os.Remove(eventsPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(eventsPath, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := recoveryCoverageRun("plan", "--last"); err == nil {
|
||||
t.Fatal("recovery plan save should fail")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("execute", "--last"); err == nil {
|
||||
t.Fatal("recovery analysis save should fail")
|
||||
}
|
||||
if _, err := recoveryCoverageRun("finalize", "--event-id", last.EventID, "--outcome", "recovered"); err == nil {
|
||||
t.Fatal("recovery finalization save should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryExecutionAndRuntimeRemainingCoverage(t *testing.T) {
|
||||
t.Setenv("DINGTALK_DEVDOC_MCP_URL", "http://127.0.0.1:1")
|
||||
path := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(path, []byte(`{"attempts":{}}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRecoveryExecution(path); err == nil {
|
||||
t.Fatal("invalid attempts should fail")
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts([]byte(`[{}`), nil, "", ""); err == nil {
|
||||
t.Fatal("invalid attempt array should fail")
|
||||
}
|
||||
|
||||
SetDynamicServers(nil)
|
||||
runtime := &recoveryRuntime{
|
||||
transport: transport.NewClient(nil),
|
||||
flags: &GlobalFlags{Token: "token"},
|
||||
}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "missing", "tool", nil); err == nil || !strings.Contains(err.Error(), `endpoint not resolved for product "missing" (tool "tool")`) {
|
||||
t.Fatalf("direct resolution error = %v", err)
|
||||
}
|
||||
if got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{}); err == nil || got.DocSearch.Status != "error" {
|
||||
t.Fatalf("search error = %#v, %v", got, err)
|
||||
}
|
||||
if got := parseDocSearchItems(&transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "not-json"}}}); got != nil {
|
||||
t.Fatalf("empty doc search items = %#v", got)
|
||||
}
|
||||
for _, payload := range []map[string]any{
|
||||
{"data": map[string]any{}},
|
||||
{"result": map[string]any{}},
|
||||
} {
|
||||
if got := parseDocSearchItemsFromMap(payload); got != nil {
|
||||
t.Fatalf("empty nested doc search items = %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
)
|
||||
|
||||
func captureRuntimeFailure(invocation executor.Invocation, rawErr, wrappedErr error) {
|
||||
if rawErr == nil && wrappedErr == nil {
|
||||
return
|
||||
}
|
||||
store := recovery.NewStore(defaultConfigDir())
|
||||
if store == nil || !store.Enabled() {
|
||||
return
|
||||
}
|
||||
input := recovery.CaptureInput{
|
||||
CommandPath: runtimeCommandPath(invocation),
|
||||
ServerID: strings.TrimSpace(invocation.CanonicalProduct),
|
||||
ToolName: strings.TrimSpace(invocation.Tool),
|
||||
Args: cloneRecoveryArgs(invocation.Params),
|
||||
Argv: append([]string(nil), os.Args[1:]...),
|
||||
RawErr: rawErr,
|
||||
WrappedErr: wrappedErr,
|
||||
}
|
||||
_, _ = store.Capture(recovery.BuildContext(input), recovery.BuildReplay(input))
|
||||
}
|
||||
|
||||
func runtimeCommandPath(invocation executor.Invocation) []string {
|
||||
if path := currentCommandPath(); len(path) > 0 {
|
||||
return path
|
||||
}
|
||||
if legacy := strings.Fields(strings.TrimSpace(invocation.LegacyPath)); len(legacy) > 0 {
|
||||
return legacy
|
||||
}
|
||||
if product := strings.TrimSpace(invocation.CanonicalProduct); product != "" {
|
||||
if tool := strings.TrimSpace(invocation.Tool); tool != "" {
|
||||
return []string{product, tool}
|
||||
}
|
||||
return []string{product}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentCommandPath() []string {
|
||||
boolFlags := map[string]struct{}{
|
||||
"--verbose": {},
|
||||
"-v": {},
|
||||
"--debug": {},
|
||||
"--mock": {},
|
||||
"--dry-run": {},
|
||||
"--yes": {},
|
||||
"-y": {},
|
||||
"--help": {},
|
||||
"-h": {},
|
||||
"--json": {},
|
||||
}
|
||||
path := make([]string, 0, len(os.Args))
|
||||
skipNext := false
|
||||
for _, arg := range os.Args[1:] {
|
||||
if skipNext {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
if arg == "--" {
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
if strings.Contains(arg, "=") {
|
||||
continue
|
||||
}
|
||||
if _, ok := boolFlags[arg]; ok {
|
||||
continue
|
||||
}
|
||||
skipNext = true
|
||||
continue
|
||||
}
|
||||
path = append(path, arg)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func cloneRecoveryArgs(args map[string]any) map[string]any {
|
||||
if len(args) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
// captureRuntimeFailure previously persisted a recovery snapshot for
|
||||
// `dws recovery`. The recovery package is gone; keep a no-op seam so runner
|
||||
// failure paths stay stable while the visible Deprecated shim remains.
|
||||
func captureRuntimeFailure(_ executor.Invocation, _, _ error) {}
|
||||
|
||||
+18
-12
@@ -39,7 +39,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"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/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
@@ -51,15 +50,11 @@ import (
|
||||
|
||||
type outputFileContextKey struct{}
|
||||
|
||||
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
||||
|
||||
var (
|
||||
rootNormalizeProcessProfileArgs = normalizeProcessProfileArgs
|
||||
rootExecuteCommand = (*cobra.Command).ExecuteC
|
||||
rootNewRootCommandWithEngine = NewRootCommandWithEngine
|
||||
rootRunPreParse = pipeline.RunPreParse
|
||||
rootLatestRecoveryCapture = recovery.LatestCapture
|
||||
rootResetRecoveryState = recovery.ResetRuntimeState
|
||||
rootStopAllStdioClients = StopAllStdioClients
|
||||
rootLoadPlugins = loadPlugins
|
||||
rootMkdirAll = os.MkdirAll
|
||||
@@ -107,7 +102,6 @@ func Execute() (exitCode int) {
|
||||
ctx = WithTimingCollector(ctx, timing)
|
||||
|
||||
initStart := time.Now()
|
||||
rootResetRecoveryState()
|
||||
engine := newPipelineEngine()
|
||||
root := rootNewRootCommandWithEngine(ctx, engine)
|
||||
timing.Record("cmd_init", time.Since(initStart))
|
||||
@@ -133,9 +127,6 @@ func Execute() (exitCode int) {
|
||||
_, _ = fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
_ = printExecutionError(executed, os.Stdout, os.Stderr, err)
|
||||
if last := rootLatestRecoveryCapture(); last != nil && last.EventID != "" {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%s%s\n", recoveryEventStderrPrefix, last.EventID)
|
||||
}
|
||||
return apperrors.ExitCode(err)
|
||||
}
|
||||
return 0
|
||||
@@ -144,6 +135,9 @@ func Execute() (exitCode int) {
|
||||
// newPreParseValidationError keeps pipeline handler identity in internal logs
|
||||
// while exposing only the underlying parameter-domain error to CLI users.
|
||||
func newPreParseValidationError(err error) error {
|
||||
if structured, ok := err.(*apperrors.Error); ok {
|
||||
return structured
|
||||
}
|
||||
userErr := err
|
||||
var handlerErr *pipeline.HandlerError
|
||||
if stderrors.As(err, &handlerErr) && handlerErr.Unwrap() != nil {
|
||||
@@ -477,10 +471,10 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
|
||||
newCatalogCommand(),
|
||||
newConfigCommand(),
|
||||
newDoctorCommand(),
|
||||
newRecoveryCommand(),
|
||||
newEventCommand(),
|
||||
newAuditCommand(),
|
||||
newCompletionCommand(root),
|
||||
newRecoveryCommand(flags),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
@@ -724,8 +718,8 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
|
||||
var builtinCommandNames = map[string]bool{
|
||||
"auth": true, "api": true, "audit": true, "cache": true, "config": true,
|
||||
"doctor": true, "event": true, "completion": true, "skill": true,
|
||||
"plugin": true, "profile": true, "version": true, "help": true,
|
||||
"recovery": true, "schema": true, "mcp": true, "upgrade": true,
|
||||
"plugin": true, "profile": true, "recovery": true, "version": true, "help": true,
|
||||
"schema": true, "mcp": true, "upgrade": true,
|
||||
}
|
||||
|
||||
// commandNameSet returns a new set containing every name in base plus extras.
|
||||
@@ -1333,6 +1327,18 @@ func registerPluginAuthFromHeaders(srv mcptypes.ServerDescriptor) {
|
||||
// - PostResponse: after transport returns, before stdout (canonical RunE)
|
||||
func newPipelineEngine() *pipeline.Engine {
|
||||
engine := pipeline.NewEngine()
|
||||
engine.SetCommandPathFallbackLookup(func(path string) (pipeline.CommandPathFallback, bool) {
|
||||
entry, ok := cli.LookupCommandPathFallback(path)
|
||||
if !ok {
|
||||
return pipeline.CommandPathFallback{}, false
|
||||
}
|
||||
return pipeline.CommandPathFallback{
|
||||
From: entry.From,
|
||||
Mode: string(entry.Mode),
|
||||
To: entry.To,
|
||||
Candidates: append([]string(nil), entry.Candidates...),
|
||||
}, true
|
||||
})
|
||||
engine.RegisterAll(
|
||||
// Register handler runs during command tree building.
|
||||
handlers.RegisterHandler{},
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -24,8 +23,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
oldExecute := rootExecuteCommand
|
||||
oldNewRoot := rootNewRootCommandWithEngine
|
||||
oldPreParse := rootRunPreParse
|
||||
oldLatest := rootLatestRecoveryCapture
|
||||
oldReset := rootResetRecoveryState
|
||||
oldStop := rootStopAllStdioClients
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
@@ -33,20 +30,16 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootExecuteCommand = oldExecute
|
||||
rootNewRootCommandWithEngine = oldNewRoot
|
||||
rootRunPreParse = oldPreParse
|
||||
rootLatestRecoveryCapture = oldLatest
|
||||
rootResetRecoveryState = oldReset
|
||||
rootStopAllStdioClients = oldStop
|
||||
os.Args = oldArgs
|
||||
})
|
||||
os.Args = []string{"dws"}
|
||||
rootNormalizeProcessProfileArgs = func() func() { return func() {} }
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
rootResetRecoveryState = func() {}
|
||||
rootStopAllStdioClients = func() {}
|
||||
rootNewRootCommandWithEngine = func(context.Context, *pipeline.Engine) *cobra.Command {
|
||||
return &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
}
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return nil }
|
||||
rootExecuteCommand = func(cmd *cobra.Command) (*cobra.Command, error) { return cmd, nil }
|
||||
if code := Execute(); code != 0 {
|
||||
t.Fatalf("successful Execute code = %d", code)
|
||||
@@ -59,7 +52,6 @@ func TestCrossPlatformCoverageRootExecuteAllBranchesCoverage(t *testing.T) {
|
||||
rootRunPreParse = func(*cobra.Command, *pipeline.Engine) error { return nil }
|
||||
|
||||
wantErr := errors.New("unknown command missing")
|
||||
rootLatestRecoveryCapture = func() *recovery.LastError { return &recovery.LastError{EventID: "evt-test"} }
|
||||
rootExecuteCommand = func(*cobra.Command) (*cobra.Command, error) { return nil, wantErr }
|
||||
if code := Execute(); code == 0 {
|
||||
t.Fatal("failed Execute returned zero")
|
||||
|
||||
@@ -419,20 +419,14 @@ func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheRefreshCompatibilityStub(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
|
||||
func TestCacheCommandDeprecatedCompatStub(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
cmd, _, err := root.Find([]string{"cache", "refresh"})
|
||||
if err != nil || cmd == nil || cmd == root {
|
||||
t.Fatalf("dws cache refresh compatibility stub missing: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "服务发现已下线"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
|
||||
}
|
||||
if cmd.Hidden || cmd.Deprecated == "" {
|
||||
t.Fatalf("cache refresh must be visible Deprecated: hidden=%v deprecated=%q", cmd.Hidden, cmd.Deprecated)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,10 +75,8 @@ func newSkillSetupCommand() *cobra.Command {
|
||||
Long: `安装 dws 自身 skill 文档到 AI Agent 目录(如 ~/.claude/skills/、~/.cursor/skills/ 等)。
|
||||
|
||||
支持两种模式:
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 🧪 EXPERIMENTAL 多 skill(试验版 / Preview)—— 按产品拆 N 个独立 skill
|
||||
尚未达到 stable 标准,接口、命名与跨 skill 引用可能变动;
|
||||
生产前请评估,问题请提 issue 反馈
|
||||
mono 单 skill(稳定 / 推荐)—— 总入口 SKILL.md + references/products/
|
||||
multi 多 skill—— 按产品拆 N 个独立 skill
|
||||
|
||||
multi 模式支持按产品挑选:
|
||||
-s/--skill 只装指定子 skill(可重复,短名 aitable 或全名 dingtalk-aitable 均可)
|
||||
@@ -150,7 +148,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
if filterErr != nil {
|
||||
return filterErr
|
||||
}
|
||||
// dws-shared carries the global rules every product skill declares as a
|
||||
// dingtalk-shared carries the global rules every product skill declares as a
|
||||
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
}
|
||||
@@ -177,8 +175,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
fmt.Fprintln(out, "已取消。")
|
||||
return nil
|
||||
}
|
||||
} else if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(errOut, "🧪 multi 模式当前为 EXPERIMENTAL(试验版 / Preview)—— 接口与布局可能变动,稳定版请用 --mode mono")
|
||||
}
|
||||
|
||||
var installed, skipped int
|
||||
@@ -205,8 +201,8 @@ const multiSkillPrefix = "dingtalk-"
|
||||
// multiSharedSkill is the shared, non-product skill that every per-product
|
||||
// skill declares as a PREREQUISITE. It must always be installed in multi mode
|
||||
// regardless of --skill / --exclude, otherwise the product skills reference a
|
||||
// dws-shared that was never installed.
|
||||
const multiSharedSkill = "dws-shared"
|
||||
// dingtalk-shared that was never installed.
|
||||
const multiSharedSkill = "dingtalk-shared"
|
||||
|
||||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
||||
@@ -365,10 +361,10 @@ func resolveSkillSetupMode(mode string, autoYes bool, out io.Writer) (string, er
|
||||
huh.NewGroup(
|
||||
huh.NewSelect[string]().
|
||||
Title("选择 dws skill 安装模式").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分(🧪 EXPERIMENTAL / 试验版,未达 stable,接口可能变动)").
|
||||
Description("mono = 单 skill 入口(稳定 / 推荐)\nmulti = 按产品拆分的独立 skill").
|
||||
Options(
|
||||
huh.NewOption("mono — 单 skill(稳定 / 推荐)", skillSetupModeMono),
|
||||
huh.NewOption("multi — 多 skill(🧪 EXPERIMENTAL · 试验版)", skillSetupModeMulti),
|
||||
huh.NewOption("multi — 多 skill(按产品拆分)", skillSetupModeMulti),
|
||||
).
|
||||
Value(&choice),
|
||||
),
|
||||
@@ -526,15 +522,6 @@ func detectExistingAgentHomes(home, mode string) []string {
|
||||
}
|
||||
|
||||
func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSkillNames []string) (bool, error) {
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintf(out, " · 当前选择的 %d 个独立 skill 均跑过 verifier,可用但未达 stable\n", len(multiSkillNames))
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
fmt.Fprintln(out, "🧪 ─────────────────────────────────────────────────────────────")
|
||||
}
|
||||
fmt.Fprintf(out, "\n📦 将安装 skill:\n mode: %s\n source: %s\n", mode, src)
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintf(out, " 将装 %d 个独立 skill(按子目录平铺到 <agent-home>/<skill-name>/):\n", len(multiSkillNames))
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
|
||||
// contains both the shared routing skill and the PAT product skill. Structured
|
||||
// contains both the shared routing skill and misc (including folded PAT docs). Structured
|
||||
// Schema hints are build inputs and must not become a third installable mode.
|
||||
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
@@ -71,9 +71,9 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
filepath.Join("dws-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "SKILL.md"),
|
||||
filepath.Join("dingtalk-pat", "references", "pat.md"),
|
||||
filepath.Join("dingtalk-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "references", "pat.md"),
|
||||
} {
|
||||
if _, err := os.Stat(filepath.Join(dir, rel)); err != nil {
|
||||
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
|
||||
|
||||
@@ -73,7 +73,7 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
t.Fatal("empty multi source should fail")
|
||||
}
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dws-shared", "dingtalk-doc"}, nil }
|
||||
skillSetupListMulti = func(string) ([]string, error) { return []string{"dingtalk-shared", "dingtalk-doc"}, nil }
|
||||
skillSetupFilterMulti = func([]string, []string, []string) ([]string, error) { return nil, fail }
|
||||
cmd = skillSetupCoverageCommand(t, skillSetupModeMulti, true)
|
||||
if err := cmd.RunE(cmd, nil); err == nil {
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
{
|
||||
"source_report": "param_hallucination_20260728_120943",
|
||||
"source_file": "raw_dashscope_qwen3_7-max_20260728_120943.json",
|
||||
"source_dws_commit": "a8e83e5",
|
||||
"model": "dashscope/qwen3.7-max",
|
||||
"expected_count": 52,
|
||||
"badcases": [
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command0",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members --group \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command1",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members --group-name \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0003-run10-command2",
|
||||
"case_id": "dws_im_v3_0003",
|
||||
"run": 10,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +members",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +members \"dws测试群01\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run1-command1",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 1,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-dm",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-dm --open-dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run1-command2",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 1,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-dm",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-dm --dingtalk-id DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run8-command4",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 8,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-single --user-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --content \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command0",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot --open-dingtalk-id \"段旭彬\" --message \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command1",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot --open-id \"段旭彬\" --msg \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0010-run10-command2",
|
||||
"case_id": "dws_im_v3_0010",
|
||||
"run": 10,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-by-bot",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-by-bot \"段旭彬\" \"评测任务更新了,稍后同步\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run1-command3",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 1,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-media",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-media --user-id \"489149\" --file-path \"建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run1-command4",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 1,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-media",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-media --receiver \"489149\" --file-path \"建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run9-command1",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 9,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 489149 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case__ysfhvr3/建筑.jpeg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0011-run9-command2",
|
||||
"case_id": "dws_im_v3_0011",
|
||||
"run": 9,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --dry-run --format json 2>&1 | head -50",
|
||||
"original_exit_code": 0,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run1-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 1,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --to \"符咏畅\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run1-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 1,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_bc6azkyi/streamlit.docx\" --user \"符咏畅\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run2-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 2,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run2-command8",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 2,
|
||||
"command_index": 8,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_3er98f5m/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run3-command1",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 3,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run3-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 3,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_cljycf8p/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run4-command3",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 4,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --help",
|
||||
"original_exit_code": 64,
|
||||
"is_help": true
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command4",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user 519483 --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command5",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --to \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run6-command6",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 6,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --name \"符咏畅\" --path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_8ef9dsey/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run7-command1",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 7,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id \"519483\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0012-run7-command2",
|
||||
"case_id": "dws_im_v3_0012",
|
||||
"run": 7,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --format json --userid \"519483\" --file \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_fjcfinmi/streamlit.docx\"",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0016-run1-command7",
|
||||
"case_id": "dws_im_v3_0016",
|
||||
"run": 1,
|
||||
"command_index": 7,
|
||||
"turn": 3,
|
||||
"source_path": "chat +rename-group",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +rename-group --id \"cideWy3UCPNZ4k8Mr4BL/8KRQ==\" --name \"测试废物群-20260713\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command6",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 6,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --group \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command7",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 7,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --mention \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run2-command9",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 2,
|
||||
"command_index": 9,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-message",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-message --format json 2>&1",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run5-command0",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 5,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +group-member-list",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +group-member-list --group-name \"dws测试群01\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command6",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 6,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-text --open-conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command7",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 7,
|
||||
"turn": 2,
|
||||
"source_path": "chat +send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-text --conversation-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --content \"@陈邦杰 请确认下评测环境\" --at-user-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0017-run6-command8",
|
||||
"case_id": "dws_im_v3_0017",
|
||||
"run": 6,
|
||||
"command_index": 8,
|
||||
"turn": 2,
|
||||
"source_path": "chat +group-send-text",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +group-send-text --group-id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --text \"@陈邦杰 请确认下评测环境\" --at-open-dingtalk-ids \"DCumk1qOOIgvuN6zOZeehqYdii06QhlcIl\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0018-run10-command5",
|
||||
"case_id": "dws_im_v3_0018",
|
||||
"run": 10,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --group \"cidyfbDkUpmy4ReyI7vfZy+zw==\" --file-path \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_z7y6p6_0/dog.png\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run4-command1",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 4,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --user-id 489149 --image /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run4-command2",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 4,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-image",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-image --to 489149 --file /private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_wsppmjwr/landscape.jpg --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run6-command3",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 6,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --receiver-id 489149 --image \"/private/var/folders/wy/34yt54fd7fv1ccgg3jpcwd140000gn/T/dws_eval_case_09930iej/landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command2",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 2,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --user-id 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command3",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-file",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-file --receiver-id 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command6",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --to 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run8-command9",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 8,
|
||||
"command_index": 9,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send-to",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send-to --uid 489149 --file \"landscape.jpg\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0019-run9-command0",
|
||||
"case_id": "dws_im_v3_0019",
|
||||
"run": 9,
|
||||
"command_index": 0,
|
||||
"turn": 1,
|
||||
"source_path": "chat +send",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +send --receiver 489149 --file-path \"./landscape.jpg\" --format json 2>&1",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command3",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 3,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --user-id 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command4",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 4,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --uid 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command5",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --staff-id 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command6",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 6,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --open-dingtalk-id \"DCumk1qOOIgsjxMVAxenQsyiPzt5zTjyzS\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command7",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 7,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single --target 550582 --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run4-command8",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 4,
|
||||
"command_index": 8,
|
||||
"turn": 1,
|
||||
"source_path": "chat +read-single",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +read-single 550582 --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0021-run5-command1",
|
||||
"case_id": "dws_im_v3_0021",
|
||||
"run": 5,
|
||||
"command_index": 1,
|
||||
"turn": 1,
|
||||
"source_path": "chat +message-list",
|
||||
"expected_outcome": "ambiguous",
|
||||
"raw": "dws chat +message-list --format json 2>&1",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run4-command5",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 4,
|
||||
"command_index": 5,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-robot",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-robot \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 5,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run5-command15",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 5,
|
||||
"command_index": 15,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-group-bots",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-group-bots --id \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
},
|
||||
{
|
||||
"id": "dws_im_v3_0028-run9-command9",
|
||||
"case_id": "dws_im_v3_0028",
|
||||
"run": 9,
|
||||
"command_index": 9,
|
||||
"turn": 1,
|
||||
"source_path": "chat +list-robots",
|
||||
"expected_outcome": "rewrite",
|
||||
"raw": "dws chat +list-robots --cid \"cidACeQ0fCtKfLsFGvA47gXaQ==\" --format json",
|
||||
"original_exit_code": 3,
|
||||
"is_help": false
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
Short: "渐进查看命令 Schema (产品 / 分组 / 工具参数)",
|
||||
Long: `查看当前可运行命令的 Schema 元数据。
|
||||
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。--all 输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)。--compact 去除 provenance / debug 字段,仅保留 Agent 选参所需信息(适合 Agent 上下文)。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
不带参数时列出产品和工具数量;传产品或分组路径逐层展开;传具体工具路径输出扁平参数 Schema(对齐 GWS:parameters 内联 required,键为 CLI flag)。普通 Agent 查询应使用 --compact:它按稳定字段白名单输出选参、约束和安全语义。省略 --compact 的 full leaf 保留参数映射、接口绑定和 provenance,仅用于定向审计;--all 输出全部工具的完整 leaf Schema,用于审计/CI。helper、MCP 与本地 Cobra 命令均须通过 ContractFinal.Identity 声明进入收集的身份集,并从同一声明装配的 ToolSpec 投影;查询不执行服务发现或临时合成第二份 Schema。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -113,7 +113,7 @@ func NewSchemaCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("all", false, "输出全部工具的完整 leaf Schema(包括参数和约束,用于审计/CI)")
|
||||
cmd.Flags().Bool("compact", false, "去除 provenance/debug 字段,仅保留 Agent 选参所需信息")
|
||||
cmd.Flags().Bool("compact", false, "按稳定字段白名单输出 Agent 选参、约束和安全语义")
|
||||
cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const commandPathFallbacksSchemaRef = "./command_path_fallbacks.schema.json"
|
||||
|
||||
// command_path_fallbacks.json is a reviewed recovery policy for invalid paths
|
||||
// observed in evaluation. It is deliberately not a command identity source:
|
||||
// Help, Skill, Schema, and normal navigation continue to publish canonical
|
||||
// paths from the CommandRegistry and the real Cobra tree only.
|
||||
|
||||
//go:embed command_path_fallbacks.json
|
||||
var embeddedCommandPathFallbacksJSON []byte
|
||||
|
||||
//go:embed command_path_fallbacks.schema.json
|
||||
var embeddedCommandPathFallbacksSchemaJSON []byte
|
||||
|
||||
var commandFallbackPathPattern = regexp.MustCompile(`^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$`)
|
||||
|
||||
// CommandPathFallbackMode determines whether an invalid reviewed path can be
|
||||
// normalized exactly or must stop and surface reviewed candidates.
|
||||
type CommandPathFallbackMode string
|
||||
|
||||
const (
|
||||
CommandPathFallbackRewrite CommandPathFallbackMode = "rewrite"
|
||||
CommandPathFallbackAmbiguous CommandPathFallbackMode = "ambiguous"
|
||||
)
|
||||
|
||||
// CommandPathFallback is one validated, generated runtime recovery record.
|
||||
// From is never advertised as a stable alias. To and Candidates are canonical
|
||||
// real Cobra paths validated at generation time.
|
||||
type CommandPathFallback struct {
|
||||
From string `json:"from"`
|
||||
Mode CommandPathFallbackMode `json:"mode"`
|
||||
To string `json:"to,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
}
|
||||
|
||||
type commandPathFallbackSnapshot struct {
|
||||
Schema string `json:"$schema"`
|
||||
Version int `json:"version"`
|
||||
Entries []commandPathFallbackEntrySpec `json:"entries"`
|
||||
}
|
||||
|
||||
type commandPathFallbackEntrySpec struct {
|
||||
From string `json:"from"`
|
||||
Mode CommandPathFallbackMode `json:"mode"`
|
||||
To string `json:"to,omitempty"`
|
||||
Candidates []string `json:"candidates,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
ReviewReason string `json:"review_reason"`
|
||||
}
|
||||
|
||||
var (
|
||||
embeddedCommandPathFallbacksOnce sync.Once
|
||||
embeddedCommandPathFallbacksData []CommandPathFallback
|
||||
embeddedCommandPathFallbacksErr error
|
||||
loadReviewedCommandPathFallbacks = loadEmbeddedCommandPathFallbacks
|
||||
)
|
||||
|
||||
// LoadCommandPathFallbacks decodes and validates the authored recovery table.
|
||||
// Callers receive a clone so generation and tests cannot mutate shared data.
|
||||
func LoadCommandPathFallbacks() ([]CommandPathFallback, error) {
|
||||
return loadReviewedCommandPathFallbacks()
|
||||
}
|
||||
|
||||
func loadEmbeddedCommandPathFallbacks() ([]CommandPathFallback, error) {
|
||||
embeddedCommandPathFallbacksOnce.Do(func() {
|
||||
embeddedCommandPathFallbacksData, embeddedCommandPathFallbacksErr = decodeCommandPathFallbacks(embeddedCommandPathFallbacksJSON)
|
||||
})
|
||||
return cloneCommandPathFallbacks(embeddedCommandPathFallbacksData), embeddedCommandPathFallbacksErr
|
||||
}
|
||||
|
||||
func decodeCommandPathFallbacks(data []byte) ([]CommandPathFallback, error) {
|
||||
var snapshot commandPathFallbackSnapshot
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&snapshot); err != nil {
|
||||
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("multiple JSON values")
|
||||
}
|
||||
return nil, fmt.Errorf("decode reviewed command path fallbacks: %w", err)
|
||||
}
|
||||
if snapshot.Version != 1 {
|
||||
return nil, fmt.Errorf("unsupported command path fallbacks version %d", snapshot.Version)
|
||||
}
|
||||
if strings.TrimSpace(snapshot.Schema) != commandPathFallbacksSchemaRef {
|
||||
return nil, fmt.Errorf("command path fallbacks must declare $schema=%q", commandPathFallbacksSchemaRef)
|
||||
}
|
||||
if len(snapshot.Entries) == 0 {
|
||||
return nil, fmt.Errorf("command path fallbacks declares no entries")
|
||||
}
|
||||
|
||||
entries := make([]CommandPathFallback, 0, len(snapshot.Entries))
|
||||
seenFrom := make(map[string]bool, len(snapshot.Entries))
|
||||
for index, spec := range snapshot.Entries {
|
||||
from, err := validateAuthoredCommandFallbackPath(spec.From)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("command path fallback entry %d from: %w", index, err)
|
||||
}
|
||||
if seenFrom[from] {
|
||||
return nil, fmt.Errorf("command path fallbacks contains duplicate from path %q", from)
|
||||
}
|
||||
seenFrom[from] = true
|
||||
reason := strings.TrimSpace(spec.ReviewReason)
|
||||
if !spec.Reviewed || reason == "" {
|
||||
return nil, fmt.Errorf("command path fallback %q requires reviewed=true and non-empty review_reason", from)
|
||||
}
|
||||
|
||||
entry := CommandPathFallback{
|
||||
From: from,
|
||||
Mode: spec.Mode,
|
||||
Reviewed: true,
|
||||
ReviewReason: reason,
|
||||
}
|
||||
switch spec.Mode {
|
||||
case CommandPathFallbackRewrite:
|
||||
if len(spec.Candidates) > 0 {
|
||||
return nil, fmt.Errorf("rewrite command path fallback %q must not declare candidates", from)
|
||||
}
|
||||
to, pathErr := validateAuthoredCommandFallbackPath(spec.To)
|
||||
if pathErr != nil {
|
||||
return nil, fmt.Errorf("rewrite command path fallback %q to: %w", from, pathErr)
|
||||
}
|
||||
entry.To = to
|
||||
case CommandPathFallbackAmbiguous:
|
||||
if strings.TrimSpace(spec.To) != "" {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q must not declare to", from)
|
||||
}
|
||||
if len(spec.Candidates) < 2 {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q requires at least two candidates", from)
|
||||
}
|
||||
seenCandidates := make(map[string]bool, len(spec.Candidates))
|
||||
for _, rawCandidate := range spec.Candidates {
|
||||
candidate, pathErr := validateAuthoredCommandFallbackPath(rawCandidate)
|
||||
if pathErr != nil {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q candidate: %w", from, pathErr)
|
||||
}
|
||||
if seenCandidates[candidate] {
|
||||
return nil, fmt.Errorf("ambiguous command path fallback %q repeats candidate %q", from, candidate)
|
||||
}
|
||||
seenCandidates[candidate] = true
|
||||
entry.Candidates = append(entry.Candidates, candidate)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("command path fallback %q has invalid mode %q", from, spec.Mode)
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].From < entries[j].From })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func validateAuthoredCommandFallbackPath(raw string) (string, error) {
|
||||
path := strings.TrimSpace(raw)
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("path is empty")
|
||||
}
|
||||
if path != raw || normalizeSchemaCLIPath(path) != path || !commandFallbackPathPattern.MatchString(path) {
|
||||
return "", fmt.Errorf("path %q is not a normalized command path without leading dws or flags", raw)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// ReduceCommandPathFallbacks validates the reviewed recovery table against the
|
||||
// live distribution-owned Cobra tree. It refuses to turn an existing command
|
||||
// or stable alias into a hidden rewrite. The sole exception is an explicitly
|
||||
// annotated hint-only compatibility node, which contains no business action.
|
||||
func ReduceCommandPathFallbacks(root *cobra.Command) ([]CommandPathFallback, error) {
|
||||
if root == nil {
|
||||
return nil, fmt.Errorf("command path fallback source root is nil")
|
||||
}
|
||||
entries, err := LoadCommandPathFallbacks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
byFrom := make(map[string]CommandPathFallback, len(entries))
|
||||
for _, entry := range entries {
|
||||
byFrom[entry.From] = entry
|
||||
}
|
||||
var problems []string
|
||||
for _, entry := range entries {
|
||||
match, resolveErr := resolveExactCobraPath(root, entry.From)
|
||||
if resolveErr != nil {
|
||||
problems = append(problems, fmt.Sprintf("from %q cannot be resolved safely: %v", entry.From, resolveErr))
|
||||
} else if match.Command != nil && !cmdutil.IsHintOnlyCommand(match.Command) {
|
||||
problems = append(problems, fmt.Sprintf("from %q collides with a real Cobra command or alias", entry.From))
|
||||
}
|
||||
|
||||
switch entry.Mode {
|
||||
case CommandPathFallbackRewrite:
|
||||
if _, chained := byFrom[entry.To]; chained {
|
||||
problems = append(problems, fmt.Sprintf("rewrite %q targets fallback source %q; chained fallbacks are forbidden", entry.From, entry.To))
|
||||
}
|
||||
problems = append(problems, validateCommandFallbackTarget(root, entry.From, entry.To, true)...)
|
||||
case CommandPathFallbackAmbiguous:
|
||||
for _, candidate := range entry.Candidates {
|
||||
if _, chained := byFrom[candidate]; chained {
|
||||
problems = append(problems, fmt.Sprintf("ambiguous fallback %q candidate %q is another fallback source", entry.From, candidate))
|
||||
}
|
||||
// Ambiguous recovery never dispatches. It may therefore point the
|
||||
// caller at both canonical shortcuts and native leaves, while exact
|
||||
// rewrites keep the stricter +shortcut identity boundary.
|
||||
problems = append(problems, validateCommandFallbackTarget(root, entry.From, candidate, false)...)
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf("fallback %q has unsupported mode %q", entry.From, entry.Mode))
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
sort.Strings(problems)
|
||||
return nil, fmt.Errorf("command path fallback reduction failed:\n - %s", strings.Join(problems, "\n - "))
|
||||
}
|
||||
return cloneCommandPathFallbacks(entries), nil
|
||||
}
|
||||
|
||||
func validateCommandFallbackTarget(root *cobra.Command, from, target string, requireShortcutParity bool) []string {
|
||||
var problems []string
|
||||
if commandFallbackService(from) != commandFallbackService(target) {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q crosses service boundary to %q", from, target))
|
||||
}
|
||||
if requireShortcutParity && commandFallbackHasShortcut(from) != commandFallbackHasShortcut(target) {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q and target %q disagree on +shortcut identity", from, target))
|
||||
}
|
||||
match, err := resolveExactCobraPath(root, target)
|
||||
if err != nil {
|
||||
return append(problems, fmt.Sprintf("fallback %q target %q cannot be resolved safely: %v", from, target, err))
|
||||
}
|
||||
if match.Command == nil {
|
||||
return append(problems, fmt.Sprintf("fallback %q target %q does not exist", from, target))
|
||||
}
|
||||
if match.UsedAlias {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q target %q must use canonical Cobra names, not an alias", from, target))
|
||||
}
|
||||
if !runnableSchemaLeaf(match.Command) || !match.Command.IsAvailableCommand() {
|
||||
problems = append(problems, fmt.Sprintf("fallback %q target %q is not a public runnable Cobra leaf", from, target))
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
func commandFallbackService(path string) string {
|
||||
parts := strings.Fields(path)
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return parts[0]
|
||||
}
|
||||
|
||||
func commandFallbackHasShortcut(path string) bool {
|
||||
for _, part := range strings.Fields(path) {
|
||||
if strings.HasPrefix(part, "+") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cloneCommandPathFallbacks(entries []CommandPathFallback) []CommandPathFallback {
|
||||
cloned := make([]CommandPathFallback, len(entries))
|
||||
for index, entry := range entries {
|
||||
cloned[index] = entry
|
||||
cloned[index].Candidates = append([]string(nil), entry.Candidates...)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
{
|
||||
"$schema": "./command_path_fallbacks.schema.json",
|
||||
"version": 1,
|
||||
"entries": [
|
||||
{
|
||||
"from": "chat +group-search",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-search",
|
||||
"reviewed": true,
|
||||
"review_reason": "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation."
|
||||
},
|
||||
{
|
||||
"from": "chat +members",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +group-members",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent."
|
||||
},
|
||||
{
|
||||
"from": "chat +group-member-list",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +group-members",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-group-bots",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-robot",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-robots",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs."
|
||||
},
|
||||
{
|
||||
"from": "chat +message-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +chat-messages",
|
||||
"chat +messages-list-direct",
|
||||
"chat +search-msg",
|
||||
"chat +unread-chats"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +read-single",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-list-direct",
|
||||
"chat +chat-messages"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch."
|
||||
},
|
||||
{
|
||||
"from": "chat +rename-group",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-update",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility."
|
||||
},
|
||||
{
|
||||
"from": "chat +send",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-message",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-text",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-to",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat +dm",
|
||||
"chat +send-to-group"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-dm",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +dm",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-single",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +dm",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-by-bot",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send-by-bot"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path."
|
||||
},
|
||||
{
|
||||
"from": "chat +group-send-text",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +send-to-group",
|
||||
"chat +messages-send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-file",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-image",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow."
|
||||
},
|
||||
{
|
||||
"from": "chat +send-media",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +messages-send",
|
||||
"chat message send"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow."
|
||||
},
|
||||
{
|
||||
"from": "oa +list-processes",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"oa +list-forms",
|
||||
"oa +my-initiated",
|
||||
"oa approval list-initiated"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-detail",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +conversation-info",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation."
|
||||
},
|
||||
{
|
||||
"from": "chat +bot-list",
|
||||
"mode": "rewrite",
|
||||
"to": "chat +chat-bots",
|
||||
"reviewed": true,
|
||||
"review_reason": "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-category-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +category-list",
|
||||
"chat +category-list-conversations"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-group-list",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +category-list-conversations",
|
||||
"chat +conversation-list"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection."
|
||||
},
|
||||
{
|
||||
"from": "chat +list-my-groups",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"chat +my-groups",
|
||||
"chat +chat-list-mine",
|
||||
"chat +chat-list"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics."
|
||||
},
|
||||
{
|
||||
"from": "doc +list-templates",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +template-list",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation."
|
||||
},
|
||||
{
|
||||
"from": "doc +search-template",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +template-search",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value."
|
||||
},
|
||||
{
|
||||
"from": "doc +template",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"doc +template-list",
|
||||
"doc +template-search",
|
||||
"doc +create-from-template"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows."
|
||||
},
|
||||
{
|
||||
"from": "doc +version",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"doc +history-list",
|
||||
"doc +history-save",
|
||||
"doc +history-revert"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop."
|
||||
},
|
||||
{
|
||||
"from": "doc +create-version",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation."
|
||||
},
|
||||
{
|
||||
"from": "doc +save-version",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target."
|
||||
},
|
||||
{
|
||||
"from": "doc +snapshot",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow."
|
||||
},
|
||||
{
|
||||
"from": "doc +version-create",
|
||||
"mode": "rewrite",
|
||||
"to": "doc +history-save",
|
||||
"reviewed": true,
|
||||
"review_reason": "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/blob/main/internal/cli/command_path_fallbacks.schema.json",
|
||||
"title": "DWS Reviewed Command Path Fallbacks",
|
||||
"description": "Human-reviewed recovery-only normalization for invalid model-emitted command paths. These entries are not stable command identity, navigation, Help, Skill, or Schema aliases.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"$schema",
|
||||
"version",
|
||||
"entries"
|
||||
],
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"const": "./command_path_fallbacks.schema.json"
|
||||
},
|
||||
"version": {
|
||||
"const": 1
|
||||
},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"commandPath": {
|
||||
"type": "string",
|
||||
"pattern": "^[A-Za-z0-9+][A-Za-z0-9._:+-]*(?: [A-Za-z0-9+][A-Za-z0-9._:+-]*)*$",
|
||||
"description": "Exact CLI path without a leading dws or flags."
|
||||
},
|
||||
"entry": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"from",
|
||||
"mode",
|
||||
"reviewed",
|
||||
"review_reason"
|
||||
],
|
||||
"properties": {
|
||||
"from": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"rewrite",
|
||||
"ambiguous"
|
||||
]
|
||||
},
|
||||
"to": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
},
|
||||
"candidates": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"uniqueItems": true,
|
||||
"description": "Canonical public runnable paths offered for an ambiguous source. Because ambiguous recovery never dispatches, candidates may mix reviewed shortcuts and native leaves within the same service.",
|
||||
"items": {
|
||||
"$ref": "#/$defs/commandPath"
|
||||
}
|
||||
},
|
||||
"reviewed": {
|
||||
"const": true
|
||||
},
|
||||
"review_reason": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "rewrite"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"to"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"candidates"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"mode": {
|
||||
"const": "ambiguous"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"mode"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"candidates"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"to"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.
|
||||
// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.
|
||||
// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).
|
||||
|
||||
package cli
|
||||
|
||||
var generatedCommandPathFallbacks = []CommandPathFallback{
|
||||
{
|
||||
From: "chat +bot-list",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260804 multi-im badcase requested the robot list for one group. +chat-bots is the unique current read-only shortcut. The fallback must not reinterpret the accompanying group flag.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-category-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +category-list", "chat +category-list-conversations"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name can mean listing the user's categories or listing conversations inside one category. No candidate may execute before the caller chooses the intended object level.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-detail",
|
||||
Mode: "rewrite",
|
||||
To: "chat +conversation-info",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260804 multi-im badcase requested one conversation's details. +conversation-info is the unique current read-only shortcut for that operation. The rewrite changes only the command path and preserves every flag/value for target validation.",
|
||||
},
|
||||
{
|
||||
From: "chat +conversation-group-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +category-list-conversations", "chat +conversation-list"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name can mean conversations in a custom category or the general conversation list. The command name alone does not identify the requested collection.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-member-list",
|
||||
Mode: "rewrite",
|
||||
To: "chat +group-members",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +group-member-list for a group-name member lookup; +group-members is the unique reviewed read-only shortcut and canonical parameter validation remains authoritative.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-search",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-search",
|
||||
Reviewed: true,
|
||||
ReviewReason: "0803 evaluation badcase: the model emitted +group-search with --query; +chat-search provides the same group-name search operation.",
|
||||
},
|
||||
{
|
||||
From: "chat +group-send-text",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +send-to-group", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +group-send-text for a group text operation, but the invented name does not choose between name-resolved group text and the unified identifier-aware workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-group-bots",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +list-group-bots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-my-groups",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "The invented name does not choose between the established resolver shortcut, the legacy personal-group list and the current Schema-complete chat list. Recovery must stop instead of silently changing pagination or output semantics.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-robot",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted singular +list-robot for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +list-robots",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-bots",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +list-robots for listing robots in one group; +chat-bots is the unique reviewed read-only shortcut. The fallback preserves flags and does not reinterpret generic IDs.",
|
||||
},
|
||||
{
|
||||
From: "chat +members",
|
||||
Mode: "rewrite",
|
||||
To: "chat +group-members",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation badcases emitted +members three times for listing members of a group selected by name; +group-members is the unique reviewed read-only shortcut for that intent.",
|
||||
},
|
||||
{
|
||||
From: "chat +message-list",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +message-list without identifying group versus direct history, conversation history versus cross-chat search, or ordinary versus unread conversations; no candidate may be selected automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +read-single",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-list-direct", "chat +chat-messages"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +read-single six times for direct-message history, but the invented name does not choose between the focused direct-history shortcut and the broader group/direct history workflow; command recovery must stop before parameter validation or dispatch.",
|
||||
},
|
||||
{
|
||||
From: "chat +rename-group",
|
||||
Mode: "rewrite",
|
||||
To: "chat +chat-update",
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +rename-group for the unique group-name update intent; +chat-update is the reviewed shortcut with that exact command-level operation. Parameter compatibility remains the canonical target's responsibility.",
|
||||
},
|
||||
{
|
||||
From: "chat +send",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-by-bot",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send-by-bot"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-by-bot three times without a complete sending-identity contract; stop and present the unified identity-aware shortcut and the exact native bot sender instead of selecting a write path.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-dm",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +dm", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-dm twice, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-file",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-file fifteen times with incompatible target and file parameter spellings; stop before dispatch and let the caller choose the unified shortcut or native current-user file workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-image",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-image without proving whether the input is an existing mediaId or a local file; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-media",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat message send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-media without a concrete media type, sending identity, or compatible parameter contract; stop before dispatch and let the caller choose the reviewed unified or native workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-message",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-message without a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-single",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +dm", "chat +messages-send"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-single for a direct message, but the invented name does not choose between the resolved-name text shortcut and the unified identifier-aware sending workflow; the write operation must not dispatch automatically.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-text",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation identified text content but not a stable recipient type or sending identity; the write operation must stop until the caller chooses the unified, resolved-direct, or resolved-group workflow.",
|
||||
},
|
||||
{
|
||||
From: "chat +send-to",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260728 evaluation emitted +send-to without proving whether the recipient denotes a user, group, or low-level identifier; the write operation must not choose a target workflow automatically.",
|
||||
},
|
||||
{
|
||||
From: "doc +create-version",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +create-version while explicitly trying to save the current document as a recoverable history snapshot. +history-save is the unique current canonical shortcut for that operation and accepts the same --node value without parameter transformation.",
|
||||
},
|
||||
{
|
||||
From: "doc +list-templates",
|
||||
Mode: "rewrite",
|
||||
To: "doc +template-list",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted +list-templates while discovering available templates. +template-list is the unique current public read-only shortcut for that operation; the rewrite changes only the command path and preserves flags for target validation.",
|
||||
},
|
||||
{
|
||||
From: "doc +save-version",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted the verb/object inversion +save-version for a current-version snapshot. +history-save is the unique current canonical shortcut with the same document-node contract; the legacy +version-save path is not selected as the fallback target.",
|
||||
},
|
||||
{
|
||||
From: "doc +search-template",
|
||||
Mode: "rewrite",
|
||||
To: "doc +template-search",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the verb/object inversion +search-template. +template-search is the unique current public read-only template-name search shortcut; the rewrite preserves every flag and value.",
|
||||
},
|
||||
{
|
||||
From: "doc +snapshot",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +snapshot during an explicit save-current-version workflow. The current +history-save selection contract names this exact recoverable history-snapshot operation, so the reviewed rewrite preserves the --node value and does not infer an update or export workflow.",
|
||||
},
|
||||
{
|
||||
From: "doc +template",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the family-like +template without selecting browse, search, or create-from-template. Command recovery must stop before parameters or execution and present the three public workflows.",
|
||||
},
|
||||
{
|
||||
From: "doc +version",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 Doc experiments emitted the family-like +version without choosing list, save, or revert. The write and high-risk revert operations cannot be selected from an umbrella name; present the canonical history shortcuts and stop.",
|
||||
},
|
||||
{
|
||||
From: "doc +version-create",
|
||||
Mode: "rewrite",
|
||||
To: "doc +history-save",
|
||||
Reviewed: true,
|
||||
ReviewReason: "202608 supplemental Doc badcase emitted +version-create while saving the current document version. +history-save is the unique current canonical snapshot shortcut and the path-only rewrite leaves its real execution and safety contract authoritative.",
|
||||
},
|
||||
{
|
||||
From: "oa +list-processes",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
|
||||
},
|
||||
}
|
||||
|
||||
func loadGeneratedCommandPathFallbacks() []CommandPathFallback {
|
||||
return generatedCommandPathFallbacks
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import "sync"
|
||||
|
||||
var (
|
||||
commandPathFallbackIndexOnce sync.Once
|
||||
commandPathFallbackIndex map[string]CommandPathFallback
|
||||
)
|
||||
|
||||
func buildCommandPathFallbackIndex() {
|
||||
entries := loadGeneratedCommandPathFallbacks()
|
||||
commandPathFallbackIndex = make(map[string]CommandPathFallback, len(entries))
|
||||
for _, entry := range entries {
|
||||
commandPathFallbackIndex[entry.From] = entry
|
||||
}
|
||||
}
|
||||
|
||||
// LookupCommandPathFallback performs an exact O(1) lookup of a reviewed
|
||||
// recovery-only path. Normalization only removes a leading dws and folds
|
||||
// whitespace; it does not apply prefix, typo, or semantic matching.
|
||||
func LookupCommandPathFallback(rawPath string) (CommandPathFallback, bool) {
|
||||
commandPathFallbackIndexOnce.Do(buildCommandPathFallbackIndex)
|
||||
entry, ok := commandPathFallbackIndex[normalizeSchemaCLIPath(rawPath)]
|
||||
if !ok {
|
||||
return CommandPathFallback{}, false
|
||||
}
|
||||
entry.Candidates = append([]string(nil), entry.Candidates...)
|
||||
return entry, true
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func commandFallbackJSON(entries string) []byte {
|
||||
return []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":1,"entries":` + entries + `}`)
|
||||
}
|
||||
|
||||
func validCommandFallbackEntry(from, to string) string {
|
||||
return `{"from":"` + from + `","mode":"rewrite","to":"` + to + `","reviewed":true,"review_reason":"reviewed fixture"}`
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDecodeCommandPathFallbacksStrictContract(t *testing.T) {
|
||||
valid := commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]`)
|
||||
entries, err := decodeCommandPathFallbacks(valid)
|
||||
if err != nil || len(entries) != 1 || entries[0].From != "chat +bad" || entries[0].To != "chat +good" {
|
||||
t.Fatalf("decode valid = %#v, %v", entries, err)
|
||||
}
|
||||
entries[0].Candidates = append(entries[0].Candidates, "mutated")
|
||||
cloned := cloneCommandPathFallbacks(entries)
|
||||
cloned[0].Candidates[0] = "clone"
|
||||
if entries[0].Candidates[0] != "mutated" {
|
||||
t.Fatal("cloneCommandPathFallbacks aliases candidate storage")
|
||||
}
|
||||
|
||||
tests := map[string]struct {
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
"unknown field": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":"x","extra":true}]`),
|
||||
want: "unknown field",
|
||||
},
|
||||
"wrong schema": {
|
||||
data: []byte(`{"$schema":"wrong","version":1,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
|
||||
want: "$schema",
|
||||
},
|
||||
"wrong version": {
|
||||
data: []byte(`{"$schema":"./command_path_fallbacks.schema.json","version":2,"entries":[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `]}`),
|
||||
want: "unsupported",
|
||||
},
|
||||
"empty entries": {data: commandFallbackJSON(`[]`), want: "no entries"},
|
||||
"unnormalized path": {
|
||||
data: commandFallbackJSON(`[` + validCommandFallbackEntry(` chat +bad `, `chat +good`) + `]`),
|
||||
want: "not a normalized",
|
||||
},
|
||||
"unreviewed": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":false,"review_reason":"x"}]`),
|
||||
want: "reviewed=true",
|
||||
},
|
||||
"empty reason": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","reviewed":true,"review_reason":" "}]`),
|
||||
want: "reviewed=true",
|
||||
},
|
||||
"invalid mode": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"guess","reviewed":true,"review_reason":"x"}]`),
|
||||
want: "invalid mode",
|
||||
},
|
||||
"rewrite candidates": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "must not declare candidates",
|
||||
},
|
||||
"empty rewrite target": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"rewrite","reviewed":true,"review_reason":"x"}]`),
|
||||
want: "path is empty",
|
||||
},
|
||||
"ambiguous target": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","to":"chat +good","candidates":["chat +one","chat +two"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "must not declare to",
|
||||
},
|
||||
"ambiguous too small": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "at least two",
|
||||
},
|
||||
"empty ambiguous candidate": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one",""],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "path is empty",
|
||||
},
|
||||
"duplicate candidate": {
|
||||
data: commandFallbackJSON(`[{"from":"chat +bad","mode":"ambiguous","candidates":["chat +one","chat +one"],"reviewed":true,"review_reason":"x"}]`),
|
||||
want: "repeats candidate",
|
||||
},
|
||||
"duplicate from": {
|
||||
data: commandFallbackJSON(`[` + validCommandFallbackEntry(`chat +bad`, `chat +good`) + `,` + validCommandFallbackEntry(`chat +bad`, `chat +other`) + `]`),
|
||||
want: "duplicate from",
|
||||
},
|
||||
"multiple documents": {
|
||||
data: append(valid, []byte(` {}`)...),
|
||||
want: "multiple JSON values",
|
||||
},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := decodeCommandPathFallbacks(test.data); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("decode error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackSchemaAndEmbeddedSource(t *testing.T) {
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(embeddedCommandPathFallbacksSchemaJSON, &schema); err != nil {
|
||||
t.Fatalf("decode embedded schema: %v", err)
|
||||
}
|
||||
if schema["$schema"] != "https://json-schema.org/draft/2020-12/schema" || schema["additionalProperties"] != false {
|
||||
t.Fatalf("schema header = %#v", schema)
|
||||
}
|
||||
entries, err := LoadCommandPathFallbacks()
|
||||
if err != nil || len(entries) != 34 {
|
||||
t.Fatalf("LoadCommandPathFallbacks() = %#v, %v", entries, err)
|
||||
}
|
||||
if got, ok := LookupCommandPathFallback("dws chat +group-search"); !ok || got.To != "chat +chat-search" {
|
||||
t.Fatalf("generated lookup = %#v, %v", got, ok)
|
||||
}
|
||||
if _, ok := LookupCommandPathFallback("chat +missing"); ok {
|
||||
t.Fatal("generated lookup accepted unknown path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
|
||||
rewrites := map[string]string{
|
||||
"chat +bot-list": "chat +chat-bots",
|
||||
"chat +conversation-detail": "chat +conversation-info",
|
||||
"chat +group-search": "chat +chat-search",
|
||||
"chat +members": "chat +group-members",
|
||||
"chat +group-member-list": "chat +group-members",
|
||||
"chat +list-group-bots": "chat +chat-bots",
|
||||
"chat +list-robot": "chat +chat-bots",
|
||||
"chat +list-robots": "chat +chat-bots",
|
||||
"chat +rename-group": "chat +chat-update",
|
||||
"doc +create-version": "doc +history-save",
|
||||
"doc +list-templates": "doc +template-list",
|
||||
"doc +save-version": "doc +history-save",
|
||||
"doc +search-template": "doc +template-search",
|
||||
"doc +snapshot": "doc +history-save",
|
||||
"doc +version-create": "doc +history-save",
|
||||
}
|
||||
for from, to := range rewrites {
|
||||
entry, ok := LookupCommandPathFallback(from)
|
||||
if !ok || entry.Mode != CommandPathFallbackRewrite || entry.To != to {
|
||||
t.Errorf("rewrite %q = %#v, %v; want %q", from, entry, ok, to)
|
||||
}
|
||||
}
|
||||
|
||||
ambiguous := map[string][]string{
|
||||
"chat +conversation-category-list": {"chat +category-list", "chat +category-list-conversations"},
|
||||
"chat +conversation-group-list": {"chat +category-list-conversations", "chat +conversation-list"},
|
||||
"chat +group-send-text": {"chat +send-to-group", "chat +messages-send"},
|
||||
"chat +list-my-groups": {"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"},
|
||||
"chat +message-list": {"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"},
|
||||
"chat +read-single": {"chat +messages-list-direct", "chat +chat-messages"},
|
||||
"chat +send": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-by-bot": {"chat +messages-send", "chat message send-by-bot"},
|
||||
"chat +send-dm": {"chat +dm", "chat +messages-send"},
|
||||
"chat +send-message": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-single": {"chat +dm", "chat +messages-send"},
|
||||
"chat +send-text": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-to": {"chat +messages-send", "chat +dm", "chat +send-to-group"},
|
||||
"chat +send-file": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-image": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-media": {"chat +messages-send", "chat message send"},
|
||||
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
}
|
||||
for from, candidates := range ambiguous {
|
||||
entry, ok := LookupCommandPathFallback(from)
|
||||
if !ok || entry.Mode != CommandPathFallbackAmbiguous || !reflect.DeepEqual(entry.Candidates, candidates) {
|
||||
t.Errorf("ambiguous %q = %#v, %v; want %v", from, entry, ok, candidates)
|
||||
}
|
||||
}
|
||||
|
||||
if entry, ok := LookupCommandPathFallback("chat +definitely-unknown"); ok {
|
||||
t.Errorf("unreviewed path unexpectedly falls back: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceCommandPathFallbacksValidatesLiveTree(t *testing.T) {
|
||||
root := commandFallbackTestRoot()
|
||||
entries := []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good", Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat group search", Mode: CommandPathFallbackRewrite, To: "chat search", Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +other"}, Reviewed: true, ReviewReason: "fixture"},
|
||||
{From: "chat +mixed", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat search"}, Reviewed: true, ReviewReason: "fixture"},
|
||||
}
|
||||
withCommandFallbackSource(t, entries)
|
||||
got, err := ReduceCommandPathFallbacks(root)
|
||||
if err != nil || len(got) != len(entries) {
|
||||
t.Fatalf("ReduceCommandPathFallbacks() = %#v, %v", got, err)
|
||||
}
|
||||
got[2].Candidates[0] = "mutated"
|
||||
again, err := ReduceCommandPathFallbacks(root)
|
||||
if err != nil || again[2].Candidates[0] == "mutated" {
|
||||
t.Fatalf("reduction did not clone candidates: %#v, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReduceCommandPathFallbacksRejectsUnsafeMappings(t *testing.T) {
|
||||
baseRoot := commandFallbackTestRoot
|
||||
tests := map[string]struct {
|
||||
entries []CommandPathFallback
|
||||
mutate func(*cobra.Command)
|
||||
want string
|
||||
}{
|
||||
"missing target": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +missing"}},
|
||||
want: "does not exist",
|
||||
},
|
||||
"real source collision": {
|
||||
entries: []CommandPathFallback{{From: "chat +other", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
want: "collides with a real Cobra command or alias",
|
||||
},
|
||||
"cobra alias collision": {
|
||||
entries: []CommandPathFallback{{From: "chat +official-alias", Mode: CommandPathFallbackRewrite, To: "chat +other"}},
|
||||
want: "collides with a real Cobra command or alias",
|
||||
},
|
||||
"cross service": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "mail +good"}},
|
||||
want: "crosses service boundary",
|
||||
},
|
||||
"shortcut mismatch": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat search"}},
|
||||
want: "disagree on +shortcut identity",
|
||||
},
|
||||
"chained rewrite": {
|
||||
entries: []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +next"},
|
||||
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +good"},
|
||||
},
|
||||
want: "chained fallbacks are forbidden",
|
||||
},
|
||||
"chained ambiguous candidate": {
|
||||
entries: []CommandPathFallback{
|
||||
{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +next"}},
|
||||
{From: "chat +next", Mode: CommandPathFallbackRewrite, To: "chat +other"},
|
||||
},
|
||||
want: "is another fallback source",
|
||||
},
|
||||
"missing ambiguous candidate": {
|
||||
entries: []CommandPathFallback{{From: "chat +choose", Mode: CommandPathFallbackAmbiguous, Candidates: []string{"chat +good", "chat +missing"}}},
|
||||
want: "does not exist",
|
||||
},
|
||||
"hidden target": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
exactSchemaCommand(root, "chat +good").Hidden = true
|
||||
},
|
||||
want: "not a public runnable",
|
||||
},
|
||||
"target is alias": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +official-alias"}},
|
||||
want: "must use canonical Cobra names",
|
||||
},
|
||||
"ambiguous source command": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +good"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
chat := exactSchemaCommand(root, "chat")
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+bad", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
},
|
||||
want: "cannot be resolved safely",
|
||||
},
|
||||
"ambiguous target command": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackRewrite, To: "chat +duplicate"}},
|
||||
mutate: func(root *cobra.Command) {
|
||||
chat := exactSchemaCommand(root, "chat")
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+duplicate", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
},
|
||||
want: "target \"chat +duplicate\" cannot be resolved safely",
|
||||
},
|
||||
"unsupported reduced mode": {
|
||||
entries: []CommandPathFallback{{From: "chat +bad", Mode: CommandPathFallbackMode("invalid")}},
|
||||
want: "unsupported mode",
|
||||
},
|
||||
}
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
root := baseRoot()
|
||||
if test.mutate != nil {
|
||||
test.mutate(root)
|
||||
}
|
||||
for index := range test.entries {
|
||||
test.entries[index].Reviewed = true
|
||||
test.entries[index].ReviewReason = "fixture"
|
||||
}
|
||||
withCommandFallbackSource(t, test.entries)
|
||||
if _, err := ReduceCommandPathFallbacks(root); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("reduction error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
withCommandFallbackLoadError(t, errors.New("fixture load"))
|
||||
if _, err := ReduceCommandPathFallbacks(baseRoot()); err == nil || !strings.Contains(err.Error(), "fixture load") {
|
||||
t.Fatalf("load error = %v", err)
|
||||
}
|
||||
if _, err := ReduceCommandPathFallbacks(nil); err == nil || !strings.Contains(err.Error(), "root is nil") {
|
||||
t.Fatalf("nil root error = %v", err)
|
||||
}
|
||||
if got := commandFallbackService(""); got != "" {
|
||||
t.Fatalf("commandFallbackService(empty) = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func commandFallbackTestRoot() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
good := &cobra.Command{Use: "+good", Aliases: []string{"+official-alias"}, Run: func(*cobra.Command, []string) {}}
|
||||
chat.AddCommand(
|
||||
good,
|
||||
&cobra.Command{Use: "+other", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "search", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
group := &cobra.Command{Use: "group"}
|
||||
group.AddCommand(cmdutil.HintSubCmd("search", "use chat search"))
|
||||
chat.AddCommand(group)
|
||||
mail := &cobra.Command{Use: "mail"}
|
||||
mail.AddCommand(&cobra.Command{Use: "+good", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(chat, mail)
|
||||
return root
|
||||
}
|
||||
|
||||
func withCommandFallbackSource(t *testing.T, entries []CommandPathFallback) {
|
||||
t.Helper()
|
||||
old := loadReviewedCommandPathFallbacks
|
||||
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) {
|
||||
return cloneCommandPathFallbacks(entries), nil
|
||||
}
|
||||
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
|
||||
}
|
||||
|
||||
func withCommandFallbackLoadError(t *testing.T, want error) {
|
||||
t.Helper()
|
||||
old := loadReviewedCommandPathFallbacks
|
||||
loadReviewedCommandPathFallbacks = func() ([]CommandPathFallback, error) { return nil, want }
|
||||
t.Cleanup(func() { loadReviewedCommandPathFallbacks = old })
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackGeneratedTableWellFormed(t *testing.T) {
|
||||
entries := loadGeneratedCommandPathFallbacks()
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("generated command path fallback table is empty")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, entry := range entries {
|
||||
if seen[entry.From] || !entry.Reviewed || strings.TrimSpace(entry.ReviewReason) == "" {
|
||||
t.Fatalf("invalid generated entry: %#v", entry)
|
||||
}
|
||||
seen[entry.From] = true
|
||||
}
|
||||
if !reflect.DeepEqual(entries, cloneCommandPathFallbacks(entries)) {
|
||||
t.Fatal("generated table clone changed values")
|
||||
}
|
||||
}
|
||||
@@ -24,39 +24,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalMCPMetadataLookup(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "reply"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
cmd.Flags().String("text", "", "text")
|
||||
runtimeannotate.AnnotateRuntimeFlag(cmd, "text", "text", "string", false)
|
||||
contractfinal.RegisterRuntimeContractFinal(cmd, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "reply_personal_message", CanonicalPath: "chat.reply_personal_message",
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
|
||||
entry := runtimeSchemaEntry{
|
||||
ProductID: "chat", ToolName: "reply_personal_message", Command: cmd,
|
||||
CLIPath: "chat reply", PrimaryCLIPath: "chat reply",
|
||||
}
|
||||
metadata := runtimeSchemaMetadataSources{
|
||||
MCP: embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{
|
||||
"text": {Type: "string"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if _, err := runtimeToolSpecFromContractFinal(entry, mustFinal(t, cmd), metadata); err != nil {
|
||||
t.Fatalf("runtimeToolSpecFromContractFinal with MCP metadata = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalPassThrough(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "create", Short: "s", Long: "l"}
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(cmd) })
|
||||
@@ -318,7 +285,7 @@ func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalSafetyAnnotationFa
|
||||
func TestCrossPlatformCoverageRuntimeToolSpecFromContractFinalParameterResolutionError(t *testing.T) {
|
||||
oldParameters := resolveRuntimeParameters
|
||||
t.Cleanup(func() { resolveRuntimeParameters = oldParameters })
|
||||
resolveRuntimeParameters = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
resolveRuntimeParameters = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return nil, errors.New("parameters failed")
|
||||
}
|
||||
entry := runtimeSchemaEntry{
|
||||
|
||||
+5
-1
@@ -27,7 +27,9 @@
|
||||
// 3. schema_parameter_mapping_ledger.go mapping exclusions / removals (Go)
|
||||
// (active bindings retired; ParamDecl.Property owns delivery)
|
||||
// 4. param_concepts.json + schema reviewed parameter synonym policy
|
||||
// 5. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
// 5. command_path_fallbacks.json + schema
|
||||
// reviewed recovery-only invalid paths
|
||||
// 6. cobra command tree (Go runtime) flags/usage/required (reflected)
|
||||
//
|
||||
// schema_hints/, schema_agent_metadata/, schema_command_registry/,
|
||||
// schema_mcp_metadata.json, and schema_mcp_service_review(.json|/ledger) are
|
||||
@@ -35,7 +37,9 @@
|
||||
//
|
||||
// Remaining generated output from this file:
|
||||
// - param_aliases_generated.go per-command parameter normalization
|
||||
// - command_path_fallbacks_generated.go invalid-path recovery normalization
|
||||
|
||||
package cli
|
||||
|
||||
//go:generate go run ../generator/cmd_param_aliases -root ../.. -output param_aliases_generated.go
|
||||
//go:generate go run ../generator/cmd_command_path_fallbacks -root ../.. -output command_path_fallbacks_generated.go
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
func TestCrossPlatformCoverageGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatalf("read internal/cli: %v", err)
|
||||
@@ -50,6 +50,7 @@ func TestGoGenerateDirectivesStayInUnifiedEntryPoint(t *testing.T) {
|
||||
for _, generator := range []string{
|
||||
"cmd_schema_catalog",
|
||||
"cmd_param_aliases",
|
||||
"cmd_command_path_fallbacks",
|
||||
} {
|
||||
if !bytes.Contains(content, []byte("//go:generate go run")) || !bytes.Contains(content, []byte(generator)) {
|
||||
t.Errorf("gen.go does not register %s", generator)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,32 +10,37 @@
|
||||
},
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +template-search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"search_query": {"denotes": "search keyword string", "canonical_hint": "query", "members": ["query", "keyword", "keywords", "q", "search-word"], "excludes": ["name", "subject", "text", "title"], "commands": ["aitable +base-search", "contact +dept-members", "contact +resolve-dept", "contact +search-user", "doc +create-from-template", "doc +find-doc", "doc +search", "doc +template-search", "doc template search", "mail +find-mail-user", "mail user search", "oa +search-forms", "oa approval search-forms"], "risk": "green"},
|
||||
"pagination_size": {"denotes": "returned item count upper bound", "canonical_hint": "limit", "members": ["limit", "size", "page-size", "max-results", "max-result", "take", "top", "per-page"], "excludes": ["count", "page", "cursor"], "commands": ["aitable record query", "calendar event list", "chat message list", "devdoc article search", "doc +comment-list", "doc +find-doc", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list", "mail thread list", "oa +list-executed"], "risk": "green"},
|
||||
"page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc block insert", "doc block update"], "risk": "green"},
|
||||
"page_cursor": {"denotes": "pagination cursor/token", "canonical_hint": "cursor", "members": ["cursor", "next-cursor", "page-token", "next-token", "next-page-token"], "excludes": ["page", "offset"], "commands": ["calendar event list", "doc +comment-list", "doc +list", "doc +search", "doc +template-list", "doc +template-search", "doc +version-list", "doc comment list", "doc template list", "doc template search", "doc version list"], "risk": "green"},
|
||||
"content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"},
|
||||
"time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"},
|
||||
"time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"},
|
||||
"base_id": {"denotes": "multi-dimensional table Base id", "canonical_hint": "base-id", "members": ["base", "base-id", "base-token"], "excludes": [], "commands": ["aitable +field-get", "aitable +list-tables", "aitable +record-query", "aitable +record-share-url", "aitable +table-get"], "risk": "green"},
|
||||
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
|
||||
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
|
||||
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat group get-by-group-id"], "risk": "yellow"},
|
||||
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "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-set-admin", "chat +chat-set-history", "chat +chat-update-alias", "chat +chat-update-nick", "chat +conversation-info", "chat +messages-list-pin", "chat +messages-read-status", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
|
||||
"group_id": {"denotes": "single DingTalk numeric groupId", "canonical_hint": "group-id", "members": ["group-id"], "excludes": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "commands": ["chat +chat-get-by-id", "chat group get-by-group-id"], "risk": "yellow"},
|
||||
"open_conversation_id": {"denotes": "single DingTalk openConversationId with unchanged value", "canonical_hint": "conversation-id", "members": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id"], "excludes": ["group-id", "group-ids", "conversation-ids", "open-conversation-ids", "group-name", "name", "id", "source", "target", "src-conversation-id", "dest-conversation-id"], "commands": ["chat +category-add-conversation", "chat +category-remove-conversation", "chat +chat-add-bot", "chat +chat-audit-join", "chat +chat-bots", "chat +chat-dismiss", "chat +chat-invite-url", "chat +chat-members-get", "chat +chat-mute", "chat +chat-mute-member", "chat +chat-quit", "chat +chat-remove-bot", "chat +chat-role-add", "chat +chat-role-list", "chat +chat-role-query-user", "chat +chat-role-remove", "chat +chat-role-remove-user", "chat +chat-role-set-user", "chat +chat-role-update", "chat +chat-set-admin", "chat +chat-set-history", "chat +chat-transfer-owner", "chat +chat-update-alias", "chat +chat-update-icon", "chat +chat-update-nick", "chat +chat-update-settings", "chat +conversation-clear-messages", "chat +conversation-clear-red-point", "chat +conversation-hide", "chat +conversation-info", "chat +conversation-mark-read", "chat +conversation-mark-unread", "chat +conversation-mute", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-list-pin", "chat +messages-read-status", "chat +messages-recall-by-bot", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-reply", "chat +messages-resource-download", "chat +messages-resource-url", "chat +messages-send-by-bot", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat category add-conv", "chat category remove-conv", "chat chmod", "chat clear-messages", "chat clear-red-point", "chat conversation-info", "chat group audit-join-validation", "chat group bots", "chat group dismiss", "chat group invite-url", "chat group members", "chat group members add", "chat group members add-bot", "chat group members list-by-ids", "chat group members remove", "chat group members remove-bot", "chat group notice create", "chat group notice edit", "chat group notice get", "chat group notice list", "chat group quit", "chat group rename", "chat group set-admin", "chat group set-history", "chat group transfer-owner", "chat group update-alias", "chat group update-icon", "chat group update-nick", "chat group update-settings", "chat group-mute", "chat group-mute-member", "chat group-role add", "chat group-role list", "chat group-role query-user", "chat group-role remove", "chat group-role remove-user", "chat group-role set-user", "chat group-role update", "chat hide", "chat mark-read", "chat mark-unread", "chat message add-favorite", "chat message download-media", "chat message list", "chat message list-mentions", "chat message list-pin-msg", "chat message list-topic-replies", "chat message read-status", "chat message recall", "chat message recall-by-bot", "chat message remove-favorite", "chat message reply", "chat message search", "chat message send", "chat message send-by-bot", "chat message send-card", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg", "chat mute-at-all", "chat mute-red-envelope", "chat set-top"], "risk": "yellow"},
|
||||
"open_conversation_ids": {"denotes": "DingTalk openConversationId list with unchanged element values", "canonical_hint": "conversation-ids", "members": ["conversation-ids", "open-conversation-ids", "groups"], "excludes": ["group-id", "group-ids", "conversation-id", "open-conversation-id", "chat-id"], "commands": ["chat message search-advanced"], "risk": "yellow"},
|
||||
"group_name": {"denotes": "group-name search keyword, not a group identifier", "canonical_hint": "group-name", "members": ["group-name"], "excludes": ["group-id", "conversation-id", "open-conversation-id", "chat-id", "id"], "commands": ["chat +group-members", "chat +send-to-group"], "risk": "yellow"},
|
||||
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +messages-read-status", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
|
||||
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
|
||||
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat message reply"], "risk": "yellow"},
|
||||
"open_message_id": {"denotes": "single DingTalk openMessageId with unchanged value", "canonical_hint": "open-message-id", "members": ["msg-id", "message-id", "open-message-id"], "excludes": ["msg-ids", "message-ids", "open-message-ids", "ref-msg-id", "src-msg-id", "open-task-id", "topic-id", "resource-id"], "commands": ["chat +conversation-mark-read", "chat +flag-cancel", "chat +flag-create", "chat +messages-add-emoji", "chat +messages-add-text-emotion", "chat +messages-forward", "chat +messages-read-status", "chat +messages-remove-emoji", "chat +messages-remove-text-emotion", "chat +messages-resource-download", "chat +messages-set-pin", "chat +messages-set-top", "chat +messages-unset-pin", "chat +messages-unset-top", "chat mark-read", "chat message add-emoji", "chat message add-favorite", "chat message add-text-emotion", "chat message download-media", "chat message forward", "chat message read-status", "chat message recall", "chat message remove-emoji", "chat message remove-favorite", "chat message remove-text-emotion", "chat message set-pin-msg", "chat message set-top-msg", "chat message unset-pin-msg", "chat message unset-top-msg"], "risk": "yellow"},
|
||||
"open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"},
|
||||
"referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"},
|
||||
"user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"},
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"},
|
||||
"ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"},
|
||||
"folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive list", "mail folder update"], "risk": "green"},
|
||||
"space_id": {"denotes": "drive/wiki space id", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["drive info"], "risk": "yellow"},
|
||||
"space_id": {"denotes": "drive/wiki/Doc workspace id with unchanged value", "canonical_hint": "space-id", "members": ["space-id", "space", "workspace", "workspace-id"], "excludes": ["folder", "node"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive info"], "risk": "yellow"},
|
||||
"app_id": {"denotes": "application id", "canonical_hint": "unified-app-id", "members": ["app-id", "unified-app-id", "application-id"], "excludes": ["app-key", "app-secret", "agent-id"], "commands": ["dev app get"], "risk": "yellow"},
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id"], "commands": ["chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat group members remove-bot"], "risk": "yellow"}
|
||||
"robot_code": {"denotes": "robot code", "canonical_hint": "robot-code", "members": ["robot-code", "robot"], "excludes": ["robot-id", "bot-id", "open-bot-id", "bot-code"], "commands": ["chat +chat-add-bot", "chat +messages-batch-recall-by-bot", "chat +messages-batch-send-by-bot", "chat +messages-recall-by-bot", "chat +messages-send-by-bot", "chat group members add-bot", "chat message recall-by-bot", "chat message send-by-bot", "ding message send"], "risk": "yellow"},
|
||||
"open_bot_id": {"denotes": "single DingTalk openBotId with unchanged value", "canonical_hint": "bot-id", "members": ["bot-id", "open-bot-id"], "excludes": ["robot-code", "robot", "robot-id", "bot-code"], "commands": ["chat +chat-remove-bot", "chat group members remove-bot"], "risk": "yellow"},
|
||||
"doc_node_id": {"denotes": "single DingTalk document nodeId or accepted document URL/token with unchanged value", "canonical_hint": "node", "members": ["node", "node-id", "doc", "doc-id", "file-id", "document-id", "url"], "excludes": ["id", "folder", "folder-id", "parent-id", "workspace", "workspace-id", "block-id", "comment-id", "comment-key", "job-id", "task-id", "template-id", "version", "revision"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +background-delete", "doc +background-update", "doc +checkpoint-update", "doc +comment-create", "doc +comment-delete", "doc +comment-list", "doc +comment-reply", "doc +comment-update", "doc +copy", "doc +doc-append", "doc +export", "doc +export-submit", "doc +fetch", "doc +history-list", "doc +history-revert", "doc +history-save", "doc +inspect", "doc +move", "doc +review", "doc +version-list", "doc +version-revert", "doc +version-save", "doc block delete", "doc block insert", "doc block list", "doc block update", "doc comment create", "doc comment create-inline", "doc comment delete", "doc comment list", "doc comment reply", "doc comment update", "doc export", "doc info", "doc media download", "doc media insert", "doc media upload", "doc read", "doc style background clear", "doc style background set", "doc style cover clear", "doc style cover set", "doc style get", "doc update", "doc version list", "doc version revert", "doc version save", "doc whiteboard insert"], "risk": "yellow"},
|
||||
"doc_comment_key": {"denotes": "single DingTalk document commentKey with unchanged value", "canonical_hint": "comment-key", "members": ["comment-key", "comment-id"], "excludes": ["id", "node", "node-id", "doc-id", "block-id"], "commands": ["doc +comment-delete", "doc +comment-reply", "doc +comment-update", "doc comment delete", "doc comment reply", "doc comment update"], "risk": "yellow"},
|
||||
"doc_version_number": {"denotes": "single DingTalk document historical version number with unchanged integer value", "canonical_hint": "version", "members": ["version", "version-number", "version-no"], "excludes": ["revision", "id", "node", "node-id", "doc-id"], "commands": ["doc +history-revert", "doc +version-revert", "doc version revert"], "risk": "yellow"},
|
||||
"doc_content_format": {"denotes": "DingTalk document body format with unchanged markdown/jsonml value", "canonical_hint": "content-format", "members": ["content-format", "doc-format"], "excludes": ["format", "export-format", "mime-type"], "commands": ["doc +create", "doc +update", "doc create", "doc update"], "risk": "green"},
|
||||
"doc_edit_revision": {"denotes": "single optimistic-concurrency revision for a document edit", "canonical_hint": "revision", "members": ["revision", "expected-revision"], "excludes": ["version", "version-number", "version-no"], "commands": ["doc +update", "doc update"], "risk": "yellow"}
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
@@ -91,10 +96,24 @@
|
||||
"chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."},
|
||||
"chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "requires a two-parameter conversion to --ref-block plus --where before; name-only normalization would silently default to after"},
|
||||
"doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true},
|
||||
"chat message send-by-bot": {"scoped_aliases": {"at-users": "at-user-ids"}, "block": ["user-id", "to-user-id"], "ambiguous": ["at-ids"], "note": "The reviewed @ userId-list alias is exact; singular recipients are not promoted, and bare --at-ids cannot choose an identifier domain."},
|
||||
"doc +export-get": {"block": ["node"], "note": "node is a document node id, a different entity from job-id"},
|
||||
"doc +export-get": {"block": ["doc-id", "document-id", "file-id", "node", "node-id", "task-id", "url"], "note": "This command queries one export jobId. Document node identifiers and import taskId spellings are different entities and are rejected.", "scoped_aliases": {"export-job-id": "job-id"}, "scope_strict": true},
|
||||
"doc block delete": {"block": ["index"], "note": "index (position) vs node (node id) are different"},
|
||||
"doc +copy": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +list": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +move": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc comment create": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment reply": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc comment update": {"scoped_aliases": {"mentioned-open-conversation-ids": "mentioned-open-conversation-id", "open-conversation-id": "mentioned-open-conversation-id", "open-conversation-ids": "mentioned-open-conversation-id"}, "block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids"], "scope_strict": true, "note": "The target is a list of mentioned openConversationIds. Explicit open-conversation spellings preserve the list value; numeric groupId and role-free conversation spellings are rejected."},
|
||||
"doc +comment-create": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
|
||||
"doc +comment-reply": {"block": ["chat-id", "chat-ids", "conversation-id", "conversation-ids", "group-id", "group-ids", "mentioned-open-conversation-id", "mentioned-open-conversation-ids", "open-conversation-id", "open-conversation-ids"], "note": "This shortcut has no group-mention flag or payload field. Group-conversation spellings are blocked instead of inventing unsupported capability; route to the stable doc comment command when group mentions are required."},
|
||||
"doc media insert": {"scoped_aliases": {"ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "block": ["before-block-id", "parent-block", "parent-block-id"], "ambiguous": ["block-id"], "scope_strict": true, "note": "Media insertion supports a reference block but no parent-block role. --before-block-id additionally needs --where before; role-free --block-id is left ambiguous."},
|
||||
"doc read": {"block": ["before-block-id", "parent-block-id", "ref-block-id", "reference-block-id"], "ambiguous": ["block-id"], "note": "A section read requires --scope section plus a start/end boundary. A role-free --block-id cannot be reduced to one flag without inventing the missing scope/boundary role."},
|
||||
"doc export get": {"scoped_aliases": {"export-job-id": "job-id"}, "block": ["doc-id", "document-id", "file-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one export jobId. Document node identifiers are rejected; native hidden --task-id remains the command's reviewed add-only compatibility alias for --job-id."},
|
||||
"doc import get": {"scoped_aliases": {"import-task-id": "task-id"}, "block": ["doc-id", "document-id", "file-id", "job-id", "node", "node-id", "url"], "scope_strict": true, "note": "This command queries one import taskId. Document node identifiers and export jobId spellings are different entities and are rejected."},
|
||||
"doc +share-doc": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a document nodeId into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc update": {"block": ["version", "version-no", "version-number"], "note": "--revision is an optimistic-concurrency revision, not a historical document version number. Version spellings must not reduce to --revision."},
|
||||
"report outbox list": {"block": ["template-type"], "note": "type vs name are different fields"},
|
||||
"chat group members add-bot": {"bind": {"id": "open_conversation_id"}},
|
||||
"chat group members list-by-ids": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["user-id", "user-ids"], "note": "This command's --id carries openConversationId, while --users carries an openDingTalkId list."},
|
||||
@@ -111,7 +130,48 @@
|
||||
"chat category create-smart": {"bind": {"members": "open_dingtalk_ids"}, "scoped_aliases": {"title": "name"}, "note": "The real --members is an openDingTalkId list; the reviewed title/name alias is exact to the category display name."},
|
||||
"chat group audit-join-validation": {"ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "note": "A role-free user identifier cannot choose between the required --applicant and --inviter roles."},
|
||||
"chat message reply": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The required --ref-sender is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."}
|
||||
"chat message send-card": {"block": ["user", "user-id", "userid", "uid", "staff-id"], "note": "The real --receiver is a role-specific openDingTalkId and must not accept generic userId spellings."},
|
||||
"chat +category-add-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +category-list-conversations": {"block": ["category-ids"], "note": "The real --category-id is singular; list cardinality is not reduced automatically."},
|
||||
"chat +category-remove-conversation": {"block": ["category-id"], "note": "The real --category-ids is a list; a singular category ID is not promoted automatically."},
|
||||
"chat +chat-add-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is the group's openConversationId; robotCode and openBotId remain different domains."},
|
||||
"chat +chat-audit-join": {"scoped_aliases": {"applicant-user-id": "applicant", "inviter-user-id": "inviter"}, "ambiguous": ["user", "user-id", "userid", "uid", "staff-id"], "scope_strict": true, "note": "A role-free user identifier cannot choose between applicant and inviter."},
|
||||
"chat +chat-create": {"block": ["user-id", "open-dingtalk-id"], "note": "The real --users field is a list and may contain mixed identifier domains; a singular value is not promoted."},
|
||||
"chat +chat-get-by-id": {"block": ["group", "conversation-id", "chat", "chat-id", "open-conversation-id", "conversation-ids", "open-conversation-ids", "group-name", "name", "id"], "note": "The real --group-id is numeric groupId; no CID or group-name spelling can be value-preservingly converted."},
|
||||
"chat +chat-members-get": {"bind": {"id": "open_conversation_id", "users": "open_dingtalk_ids"}, "block": ["group", "group-name", "user-id", "user-ids"], "note": "The real --id is openConversationId and --users is an openDingTalkId list. The observed --group spelling carried a natural group name and is blocked; explicit CID spellings and --chat remain value-preserving aliases."},
|
||||
"chat +chat-members-list": {"scoped_aliases": {"chat-id": "conversation-id", "id": "conversation-id"}, "block": ["query", "keyword", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "Native --chat/--open-conversation-id stay native; member filtering by query is unsupported and group-name resolution stays on --group/--chat-query."},
|
||||
"chat +chat-mute-member": {"scoped_aliases": {"user-ids": "users", "open-dingtalk-ids": "users"}, "block": ["user", "user-id", "open-dingtalk-id"], "scope_strict": true, "note": "The target accepts a mixed identifier list; list spellings preserve values, but singular inputs are not promoted."},
|
||||
"chat +chat-remove-bot": {"bind": {"id": "open_conversation_id"}, "note": "The real --id is openConversationId; --bot-id is separately governed by open_bot_id."},
|
||||
"chat +chat-role-remove": {"block": ["role-ids"], "note": "The command removes one role ID; list cardinality is not reduced."},
|
||||
"chat +chat-role-remove-user": {"scoped_aliases": {"user-id": "user", "open-dingtalk-id": "user"}, "block": ["role-id"], "scope_strict": true, "note": "The single --user accepts either identifier domain; --role-ids remains a list."},
|
||||
"chat +chat-transfer-owner": {"scoped_aliases": {"user-id": "new-owner", "open-dingtalk-id": "new-owner"}, "scope_strict": true, "note": "The only user role is the new owner, and the target accepts either userId or openDingTalkId without changing the value."},
|
||||
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
|
||||
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
|
||||
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
|
||||
"chat +flag-list": {"scoped_aliases": {"limit": "size"}, "block": ["max", "max-results", "max-size", "count", "page", "page-size", "per-page"], "scope_strict": true, "note": "Only limit and size are reviewed as the same page bound; total-count and page-number spellings are not equivalent."},
|
||||
"chat +messages-batch-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values returned by bot sending; it is not an openMessageId field."},
|
||||
"chat +messages-combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles remain explicit; role-free CID spellings cannot choose a side."},
|
||||
"chat +messages-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "msg-id", "source-message-id": "msg-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "The message role is uniquely the source message, but source/destination conversation roles cannot be inferred from a generic CID."},
|
||||
"chat +messages-forward-topic": {"scoped_aliases": {"src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "src-msg-id", "source-message-id": "src-msg-id"}, "block": ["group-id", "group-ids", "msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles and the source-message role remain explicit; role-free message IDs and list cardinality are not inferred."},
|
||||
"chat +messages-list": {"scoped_aliases": {"start": "time"}, "block": ["before", "before-time", "end", "direction", "page-all", "count", "max", "max-results", "max-size", "page-size"], "scope_strict": true, "note": "start preserves the same boundary value; before/direction require multi-parameter or value transforms and page-all requires iteration. Native --conversation-id/--id/--size remain native."},
|
||||
"chat +messages-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values, not openMessageId values."},
|
||||
"chat +messages-reply": {"scoped_aliases": {"msg-id": "ref-msg-id", "open-message-id": "ref-msg-id"}, "block": ["group", "msg-ids", "message-ids", "open-message-ids"], "scope_strict": true, "note": "The observed --group spelling carried a natural group name and is blocked. The only message role is the referenced message; plural IDs are not accepted, while --chat remains a CID alias and native --message-id stays native."},
|
||||
"chat +messages-resource-download": {"block": ["download-dir"], "note": "--output may be a file or directory under workspace safety rules; a download directory cannot be assumed equivalent."},
|
||||
"doc +create": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["content-file", "parent-id"], "scope_strict": true, "note": "--content-format is value-preservingly normalized to --doc-format. A raw --content-file path cannot become --content without adding the required @file transform, so it is blocked with guidance to use @relative-path or stable doc create."},
|
||||
"doc +create-from-template": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +import": {"scoped_aliases": {"folder-id": "folder", "parent-folder": "folder", "parent-folder-id": "folder", "parent-node-id": "folder"}, "block": ["parent-id"], "scope_strict": true, "note": "This exact Doc command expects a Doc folder nodeId/dentryUuid/URL. Reviewed folder spellings preserve that value; generic --parent-id stays blocked because it may carry a numeric Drive dentryId."},
|
||||
"doc +update": {"scoped_aliases": {"mode": "command", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node", "url": "node"}, "block": ["content-file"], "scope_strict": true, "note": "--mode append/overwrite is the same operation selector subset as --command and preserves its value. --content-file is blocked because +update requires @relative-path or stdin and central aliases cannot read/transform a file value."},
|
||||
"doc +inspect": {"scoped_aliases": {"include-versions": "include-history"}, "block": ["include", "include-info"], "scope_strict": true, "note": "Historical versions and history are the same optional section on this exact shortcut. Generic --include needs value-dependent flag expansion, while base document info is always returned, so those spellings are rejected with precise guidance."},
|
||||
"doc +fetch": {"scoped_aliases": {"start-block": "start-block-id", "end-block": "end-block-id"}, "ambiguous": ["block-id"], "scope_strict": true, "note": "Start/end block roles are preserved. A role-free --block-id cannot choose a range/section boundary and must stop before execution."},
|
||||
"doc +media-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose between document and media identities."},
|
||||
"doc +media-insert": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and local-media roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +media-list": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "The command lists media inside one document, so only strong document spellings map to --node. File and URL spellings remain role-ambiguous."},
|
||||
"doc +media-preview": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has both document-node and attachment-resource roles. Strong document spellings map to --node; --file-id and --url cannot safely choose a role."},
|
||||
"doc +resource-delete": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command removes a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-download": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command downloads a document resource while targeting the document by --node. File and URL spellings do not uniquely identify that document role."},
|
||||
"doc +resource-update": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "node-id": "node"}, "ambiguous": ["file-id", "url"], "scope_strict": true, "note": "This command has document-node, local-file, and HTTPS image URL roles. Only strong document spellings map to --node; --file-id and --url must stop as ambiguous."},
|
||||
"doc +share": {"block": ["doc", "doc-id", "document-id", "file-id", "id", "node", "node-id"], "note": "The real --url requires a shareable document link. Name-only normalization cannot turn a node identifier into a URL, so identifier spellings are rejected with guidance to provide --url."},
|
||||
"doc +grant-and-share": {"scoped_aliases": {"doc": "node", "doc-id": "node", "document-id": "node", "file-id": "node", "node-id": "node"}, "scope_strict": true, "note": "This workflow has two different real URL roles: --node selects the document for access control and --url is the shareable link sent to recipients. Explicit document-ID spellings map only to --node; --url remains native."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
@@ -258,8 +318,117 @@
|
||||
{"command": "chat +unread-chats", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "chat category rename", "emitted": "name", "expect": "title", "via": "override:scoped"},
|
||||
{"command": "chat message list-unread-conversations", "emitted": "size", "expect": "count", "via": "override:scoped"},
|
||||
{"command": "doc +comment-create", "emitted": "node-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +doc-append", "emitted": "node", "expect": "doc", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +find-doc", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +search", "emitted": "q", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +comment-list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +list", "emitted": "page-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +copy", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +copy", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +copy", "emitted": "parent-id", "expect": "did-you-mean:blocked", "via": "guard:doc-folder-value-domain"},
|
||||
{"command": "doc +comment-reply", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc comment reply", "emitted": "mentioned-open-conversation-ids", "expect": "mentioned-open-conversation-id", "via": "override:scoped-role-list"},
|
||||
{"command": "doc comment reply", "emitted": "group-id", "expect": "did-you-mean:blocked", "via": "guard:numeric-group-id-vs-open-conversation-id"},
|
||||
{"command": "doc +comment-create", "emitted": "mentioned-open-conversation-id", "expect": "did-you-mean:blocked", "via": "guard:shortcut-missing-capability"},
|
||||
{"command": "doc block insert", "emitted": "parent-block-id", "expect": "parent-block", "via": "override:scoped-block-role"},
|
||||
{"command": "doc block insert", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:parent-vs-reference-block-role"},
|
||||
{"command": "doc media insert", "emitted": "before-block-id", "expect": "did-you-mean:blocked", "via": "guard:requires-ref-block-plus-where"},
|
||||
{"command": "doc read", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:requires-scope-and-boundary-role"},
|
||||
{"command": "doc export get", "emitted": "node", "expect": "did-you-mean:blocked", "via": "guard:document-node-vs-export-job"},
|
||||
{"command": "doc import get", "emitted": "job-id", "expect": "did-you-mean:blocked", "via": "guard:export-job-vs-import-task"},
|
||||
{"command": "doc +version-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +version-revert", "emitted": "revision", "expect": "did-you-mean:blocked", "via": "concept:doc_version_number+exclude"},
|
||||
{"command": "doc update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +share-doc", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"},
|
||||
{"command": "doc +comment-create", "emitted": "body", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc +doc-append", "emitted": "content", "expect": "text", "via": "concept:content_text"},
|
||||
{"command": "doc +export-submit", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +move", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +template-list", "emitted": "next-token", "expect": "cursor", "via": "concept:page_cursor"},
|
||||
{"command": "doc +version-list", "emitted": "page-size", "expect": "limit", "via": "concept:pagination_size"},
|
||||
{"command": "doc +version-save", "emitted": "file-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc comment create", "emitted": "text", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc comment create-inline", "emitted": "body", "expect": "content", "via": "concept:content_text"},
|
||||
{"command": "doc comment delete", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc comment update", "emitted": "comment-id", "expect": "comment-key", "via": "concept:doc_comment_key"},
|
||||
{"command": "doc version revert", "emitted": "version-no", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "chat +chat-messages", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
|
||||
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"}
|
||||
{"command": "chat +search-msg", "emitted": "chat", "expect": "group", "via": "override:scoped-eval"},
|
||||
{"command": "chat +chat-update", "emitted": "chat-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "open-conversation-id", "expect": "group", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
|
||||
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
|
||||
{"command": "chat +flag-list", "emitted": "limit", "expect": "size", "via": "override:scoped-page-bound"},
|
||||
{"command": "chat +flag-list", "emitted": "max", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +flag-list", "emitted": "max-size", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +chat-members-list", "emitted": "chat-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-members-list", "emitted": "id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +chat-members-list", "emitted": "query", "expect": "did-you-mean:blocked", "via": "guard:unsupported-member-filter"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "open-conversation-id", "expect": "conversation-id", "via": "override:scoped-explicit-cid"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "groups", "expect": "did-you-mean:blocked", "via": "guard:group-name-or-list-ambiguity"},
|
||||
{"command": "chat +conversation-set-top", "emitted": "top", "expect": "did-you-mean:blocked", "via": "guard:inverse-boolean-semantics"},
|
||||
{"command": "chat +chat-members-get", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-members-get", "emitted": "open-dingtalk-ids", "expect": "users", "via": "concept:open_dingtalk_ids+bind"},
|
||||
{"command": "chat +chat-members-get", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +chat-members-get", "emitted": "chat", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-get-by-id", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:open-conversation-id-vs-numeric-group-id"},
|
||||
{"command": "chat +messages-list", "emitted": "start", "expect": "time", "via": "override:scoped-time-boundary"},
|
||||
{"command": "chat +messages-list", "emitted": "count", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +messages-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
|
||||
{"command": "chat +messages-list", "emitted": "page-all", "expect": "did-you-mean:blocked", "via": "guard:requires-pagination-loop"},
|
||||
{"command": "chat +messages-reply", "emitted": "msg-id", "expect": "ref-msg-id", "via": "override:scoped-reference-message"},
|
||||
{"command": "chat +messages-reply", "emitted": "group", "expect": "did-you-mean:blocked", "via": "guard:group-name-vs-open-conversation-id"},
|
||||
{"command": "chat +messages-reply", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-cancel", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-cancel", "emitted": "chat", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +flag-create", "emitted": "group", "expect": "conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "conversation-id", "expect": "id", "via": "concept:open_conversation_id+bind"},
|
||||
{"command": "chat +chat-add-bot", "emitted": "robot", "expect": "robot-code", "via": "concept:robot_code"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "applicant-user-id", "expect": "applicant", "via": "override:scoped-user-role"},
|
||||
{"command": "chat +chat-audit-join", "emitted": "user-id", "expect": "did-you-mean:ambiguous", "via": "guard:applicant-vs-inviter-role"},
|
||||
{"command": "chat +chat-create", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +chat-mute-member", "emitted": "user-ids", "expect": "users", "via": "override:scoped-mixed-id-list"},
|
||||
{"command": "chat +chat-mute-member", "emitted": "user-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +chat-remove-bot", "emitted": "open-bot-id", "expect": "bot-id", "via": "concept:open_bot_id"},
|
||||
{"command": "chat +chat-role-remove", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "guard:list-vs-single"},
|
||||
{"command": "chat +chat-role-remove-user", "emitted": "open-dingtalk-id", "expect": "user", "via": "override:scoped-mixed-id"},
|
||||
{"command": "chat +chat-transfer-owner", "emitted": "user-id", "expect": "new-owner", "via": "override:scoped-owner-role"},
|
||||
{"command": "chat +feed-group-query-item", "emitted": "chat-ids", "expect": "conversation-ids", "via": "override:scoped-explicit-cid-list"},
|
||||
{"command": "chat +feed-group-query-item", "emitted": "conversation-id", "expect": "did-you-mean:blocked", "via": "guard:single-vs-list"},
|
||||
{"command": "chat +messages-batch-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
|
||||
{"command": "chat +messages-combine-forward", "emitted": "src-open-cid", "expect": "src-conversation-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-combine-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
|
||||
{"command": "chat +messages-forward", "emitted": "source-message-id", "expect": "msg-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-forward", "emitted": "conversation-id", "expect": "did-you-mean:ambiguous", "via": "guard:source-vs-destination-role"},
|
||||
{"command": "chat +messages-forward-topic", "emitted": "src-open-message-id", "expect": "src-msg-id", "via": "override:scoped-source-role"},
|
||||
{"command": "chat +messages-forward-topic", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:message-source-role"},
|
||||
{"command": "chat +messages-recall-by-bot", "emitted": "message-id", "expect": "did-you-mean:blocked", "via": "guard:process-query-key-vs-open-message-id"},
|
||||
{"command": "chat +messages-resource-download", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "chat +messages-resource-download", "emitted": "download-dir", "expect": "did-you-mean:blocked", "via": "guard:output-file-or-directory-contract"},
|
||||
{"command": "chat +messages-set-pin", "emitted": "conversation-id", "expect": "open-conversation-id", "via": "concept:open_conversation_id"},
|
||||
{"command": "doc +create", "emitted": "content-format", "expect": "doc-format", "via": "concept:doc_content_format"},
|
||||
{"command": "doc +create", "emitted": "content-file", "expect": "did-you-mean:blocked", "via": "guard:requires-file-read-transform"},
|
||||
{"command": "doc +inspect", "emitted": "include-versions", "expect": "include-history", "via": "override:scoped-section"},
|
||||
{"command": "doc +inspect", "emitted": "include", "expect": "did-you-mean:blocked", "via": "guard:requires-value-dependent-flag-expansion"},
|
||||
{"command": "doc +inspect", "emitted": "include-info", "expect": "did-you-mean:blocked", "via": "guard:base-info-always-returned"},
|
||||
{"command": "doc +update", "emitted": "mode", "expect": "command", "via": "override:scoped-operation"},
|
||||
{"command": "doc +update", "emitted": "revision", "expect": "expected-revision", "via": "concept:doc_edit_revision"},
|
||||
{"command": "doc +update", "emitted": "version", "expect": "did-you-mean:blocked", "via": "guard:historical-version-vs-edit-revision"},
|
||||
{"command": "doc +fetch", "emitted": "start-block", "expect": "start-block-id", "via": "override:scoped-boundary-role"},
|
||||
{"command": "doc +fetch", "emitted": "block-id", "expect": "did-you-mean:ambiguous", "via": "guard:start-vs-end-boundary-role"},
|
||||
{"command": "doc +access-grant", "emitted": "doc-id", "expect": "node", "via": "concept:doc_node_id"},
|
||||
{"command": "doc +history-revert", "emitted": "version-number", "expect": "version", "via": "concept:doc_version_number"},
|
||||
{"command": "doc +create-from-template", "emitted": "keyword", "expect": "query", "via": "concept:search_query"},
|
||||
{"command": "doc +create-from-template", "emitted": "workspace-id", "expect": "workspace", "via": "concept:space_id"},
|
||||
{"command": "doc +create-from-template", "emitted": "parent-folder-id", "expect": "folder", "via": "override:scoped-doc-folder"},
|
||||
{"command": "doc +media-download", "emitted": "file-id", "expect": "did-you-mean:ambiguous", "via": "guard:document-node-vs-attachment-resource-role"},
|
||||
{"command": "doc +resource-update", "emitted": "url", "expect": "did-you-mean:ambiguous", "via": "guard:document-url-vs-image-url-role"},
|
||||
{"command": "doc +share", "emitted": "node-id", "expect": "did-you-mean:blocked", "via": "guard:node-id-needs-url-conversion"}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+85
-184
@@ -89,9 +89,9 @@ func RegisterRuntimeSchemaConstraints(canonicalPath string, constraints RuntimeS
|
||||
}
|
||||
|
||||
// emptyPinnedMCPMetadata returns the retired pin shape with no tools.
|
||||
// schema_mcp_metadata.json is deleted; production assembly does not embed or
|
||||
// load a pinned MCP snapshot. Test fixtures may still inject non-empty maps
|
||||
// through schemaRegistryForTestWithMetadata.
|
||||
// schema_mcp_metadata.json is deleted; Schema parameter assembly never loads
|
||||
// or ranks MCP pin candidates. Optional Interface-registry validators may
|
||||
// still pass this empty shape when they only need ContractFinal self-checks.
|
||||
func emptyPinnedMCPMetadata() embeddedMCPMetadata {
|
||||
return embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
|
||||
}
|
||||
@@ -217,62 +217,6 @@ func collectRuntimeSchemaEntriesFromBound(bound BoundCommandRegistry) ([]runtime
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func pinnedMCPMetadataForEntryFrom(entry runtimeSchemaEntry, agentMetadata agentMetadata, mcpMetadata embeddedMCPMetadata) (embeddedMCPToolMetadata, bool) {
|
||||
// Optional test/diagnostic lookup only. Production mcpMetadata is empty;
|
||||
// Contract/ParamDecl own interface facts. When a non-empty fixture is
|
||||
// injected, ContractFinal Interface.Ref remaps CLI canonical names onto
|
||||
// fixture keys (e.g. reply_personal_message → chat.send_personal_message).
|
||||
if len(mcpMetadata.Tools) == 0 {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
if entry.Command != nil {
|
||||
if final, ok := RuntimeContractFinal(entry.Command); ok && final.Interface != nil && final.Interface.Ref != nil {
|
||||
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, final.Interface.Ref.ProductID, final.Interface.Ref.RPCName); found {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
}
|
||||
paths := []string{
|
||||
entry.PrimaryCLIPath,
|
||||
entry.CLIPath,
|
||||
entry.ProductID + "." + entry.ToolName,
|
||||
}
|
||||
paths = append(paths, entry.Aliases...)
|
||||
if toolMetadata, ok := lookupAgentToolMetadataFrom(agentMetadata, paths...); ok && toolMetadata.InterfaceRef != nil {
|
||||
if metadata, found := mcpMetadataForInterfaceRef(mcpMetadata, toolMetadata.InterfaceRef.ProductID, toolMetadata.InterfaceRef.RPCName); found {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
for _, key := range []string{
|
||||
entry.SourceProductID + "." + entry.ToolName,
|
||||
entry.ProductID + "." + entry.ToolName,
|
||||
} {
|
||||
key = strings.Trim(key, ".")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if meta, ok := mcpMetadata.Tools[key]; ok {
|
||||
return meta, true
|
||||
}
|
||||
}
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
|
||||
func mcpMetadataForInterfaceRef(mcpMetadata embeddedMCPMetadata, productID, rpcName string) (embeddedMCPToolMetadata, bool) {
|
||||
productID = strings.TrimSpace(productID)
|
||||
rpcName = strings.TrimSpace(rpcName)
|
||||
key := strings.Trim(productID+"."+rpcName, ".")
|
||||
if key == "" {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
metadata, exists := mcpMetadata.Tools[key]
|
||||
if !exists {
|
||||
return embeddedMCPToolMetadata{}, false
|
||||
}
|
||||
metadata.InterfaceRef = &embeddedMCPInterfaceRef{ProductID: productID, RPCName: rpcName}
|
||||
return metadata, true
|
||||
}
|
||||
|
||||
func runtimeSchemaAnnotations(cmd *cobra.Command) (productID, toolName, source string) {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return "", "", ""
|
||||
@@ -322,7 +266,6 @@ const (
|
||||
runtimeSchemaRankDefault = 0
|
||||
runtimeSchemaRankDerived = 50
|
||||
runtimeSchemaRankInference = 100
|
||||
runtimeSchemaRankMCP = 400
|
||||
runtimeSchemaRankCobraHelp = 450
|
||||
runtimeSchemaRankCobraDefault = 600
|
||||
runtimeSchemaRankCobraContract = 610
|
||||
@@ -332,7 +275,7 @@ const (
|
||||
runtimeSchemaRankVersionedBinding = 650
|
||||
// ParamDecl.Property (dws.schema.property) outranks residual versioned
|
||||
// binding candidates (active bindings JSON is empty after Phase 2).
|
||||
// Mapping exclusions stay highest so an explicit "no MCP property" review
|
||||
// Mapping exclusions stay highest so an explicit "no RPC property" review
|
||||
// cannot be overridden by a leaf ParamDecl that still carries a Property.
|
||||
runtimeSchemaRankParamDeclProperty = 655
|
||||
runtimeSchemaRankMappingExclusion = 660
|
||||
@@ -340,7 +283,6 @@ const (
|
||||
runtimeSchemaPrecedenceDefault = "default"
|
||||
runtimeSchemaPrecedenceDerived = "derived_resolution"
|
||||
runtimeSchemaPrecedenceInference = "inference"
|
||||
runtimeSchemaPrecedenceMCP = "mcp_metadata"
|
||||
runtimeSchemaPrecedenceCobraHelp = "cobra_help"
|
||||
runtimeSchemaPrecedenceCobra = "cobra_contract"
|
||||
runtimeSchemaPrecedenceNativeAnnotation = "native_annotation"
|
||||
@@ -533,8 +475,6 @@ func runtimeSchemaSourcePriority(source string) (int, string) {
|
||||
return runtimeSchemaRankCobraDefault, runtimeSchemaPrecedenceCobra
|
||||
}
|
||||
return runtimeSchemaRankCobraContract, runtimeSchemaPrecedenceCobra
|
||||
case "mcp_metadata", "pinned_mcp_metadata":
|
||||
return runtimeSchemaRankMCP, runtimeSchemaPrecedenceMCP
|
||||
case "cobra_help":
|
||||
return runtimeSchemaRankCobraHelp, runtimeSchemaPrecedenceCobraHelp
|
||||
case "flag_name_inference", "usage_required_inference", "usage_format_inference":
|
||||
@@ -575,9 +515,9 @@ func runtimeSchemaParameterMappingKey(canonicalPath, flagName string) string {
|
||||
|
||||
// runtimeSchemaParameterMappingCandidates resolves the two reviewed,
|
||||
// versioned property-mapping inputs. An exclusion is an explicit statement
|
||||
// that the CLI parameter is not a direct MCP property: it therefore supplies
|
||||
// a present empty candidate (rather than allowing name inference to survive)
|
||||
// and keeps the review reason in provenance.
|
||||
// that the CLI parameter is not a direct RPC/interface property: it therefore
|
||||
// supplies a present empty candidate (rather than allowing name inference to
|
||||
// survive) and keeps the review reason in provenance.
|
||||
func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnapshot, canonicalPath, flagName string) (runtimeSchemaFieldCandidate, runtimeSchemaFieldCandidate, error) {
|
||||
binding := strings.TrimSpace(snapshot.Bindings[strings.TrimSpace(canonicalPath)][strings.TrimSpace(flagName)])
|
||||
bindingCandidate := runtimeSchemaStringCandidate(binding, "versioned_parameter_binding")
|
||||
@@ -605,34 +545,24 @@ func runtimeSchemaParameterMappingCandidates(snapshot schemaParameterBindingSnap
|
||||
type runtimeParameterFieldContext struct {
|
||||
flag *pflag.Flag
|
||||
metadata RuntimeSchemaParameterMetadata
|
||||
pinnedParam embeddedMCPParamMeta
|
||||
hasPinned bool
|
||||
paramType string
|
||||
constraints RuntimeSchemaConstraints
|
||||
property string
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) interfaceTypeCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagTypeAnnotation), "native_annotation"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Type, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates,
|
||||
runtimeSchemaStringCandidateAtRank(c.paramType, "cobra_flag_type", runtimeSchemaRankInference, "fallback"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) descriptionCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagDescriptionAnnotation), "native_annotation"),
|
||||
runtimeSchemaStringCandidate(c.flag.Usage, "cobra_usage"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Description, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate("", true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldCandidate {
|
||||
@@ -649,7 +579,7 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
|
||||
break
|
||||
}
|
||||
}
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
constraintRequired,
|
||||
runtimeSchemaCandidate(true, typedRequired, "typed_parameter_metadata"),
|
||||
runtimeSchemaAnnotatedBoolCandidate(c.flag, runtimeSchemaFlagMetadataRequiredAnnotation, "typed_parameter_metadata"),
|
||||
@@ -657,50 +587,36 @@ func (c runtimeParameterFieldContext) requiredCandidates() []runtimeSchemaFieldC
|
||||
runtimeSchemaCandidate(true, runtimeFlagCobraHardRequired(c.flag), "cobra_hard_required"),
|
||||
runtimeSchemaCandidate(false, cobraDefaultOptional, "cobra_nonzero_default"),
|
||||
runtimeSchemaCandidate(usageRequired, usageRequired, "usage_required_inference"),
|
||||
runtimeSchemaCandidate(false, true, "default"),
|
||||
}
|
||||
if c.hasPinned && c.pinnedParam.Required != nil {
|
||||
candidates = append(candidates, runtimeSchemaCandidate(*c.pinnedParam.Required, true, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate(false, true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) requiredWhenCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(c.metadata.RequiredWhen[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataRequiredWhenAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagRequiredWhenAnnotation), "native_annotation"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.RequiredWhen, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate("", true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) formatCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaStringCandidate(c.metadata.Formats[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, runtimeSchemaFlagMetadataFormatAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaStringCandidate(firstFlagAnnotation(c.flag, "x-cli-format"), "native_annotation"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaStringCandidate(c.pinnedParam.Format, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates,
|
||||
runtimeSchemaStringCandidate(inferredRuntimeFlagFormat(c.flag), "usage_format_inference"),
|
||||
runtimeSchemaCandidate("", true, "default"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) enumCandidates() []runtimeSchemaFieldCandidate {
|
||||
candidates := []runtimeSchemaFieldCandidate{
|
||||
return []runtimeSchemaFieldCandidate{
|
||||
runtimeSchemaEnumCandidate(c.metadata.Enums[c.flag.Name], "typed_parameter_metadata"),
|
||||
runtimeSchemaEnumCandidate(runtimeFlagEnumAnnotation(c.flag, runtimeSchemaFlagMetadataEnumAnnotation), "typed_parameter_metadata"),
|
||||
runtimeSchemaEnumCandidate(runtimeFlagEnum(c.flag), "native_annotation"),
|
||||
runtimeSchemaCandidate([]string{}, true, "default"),
|
||||
}
|
||||
if c.hasPinned {
|
||||
candidates = append(candidates, runtimeSchemaEnumCandidate(c.pinnedParam.Enum, "mcp_metadata"))
|
||||
}
|
||||
return append(candidates, runtimeSchemaCandidate([]string{}, true, "default"))
|
||||
}
|
||||
|
||||
func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCandidate {
|
||||
@@ -717,7 +633,8 @@ func (c runtimeParameterFieldContext) exampleCandidates() []runtimeSchemaFieldCa
|
||||
// source may intentionally raise or lower type/mapping/description semantics.
|
||||
// required is different: Cobra MarkFlagRequired is a hard floor that no
|
||||
// lower-priority source may demote (see resolveRequiredProjection).
|
||||
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
// MCP pin / mcp_metadata is not a candidate source.
|
||||
func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
if cmd == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -758,18 +675,10 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
return
|
||||
}
|
||||
property, _ := propertyWinner.Value.(string)
|
||||
// pinnedParams remains for test fixtures that inject MCP-shaped maps;
|
||||
// production assembly always passes an empty map (pin retired).
|
||||
pinnedParam, hasPinnedParam := embeddedMCPParamMeta{}, false
|
||||
if len(pinnedParams) > 0 && strings.TrimSpace(property) != "" {
|
||||
pinnedParam, hasPinnedParam = lookupPinnedMCPParam(pinnedParams, property, flag.Name)
|
||||
}
|
||||
paramType := runtimeFlagCLIType(flag)
|
||||
fieldCtx := runtimeParameterFieldContext{
|
||||
flag: flag,
|
||||
metadata: metadata,
|
||||
pinnedParam: pinnedParam,
|
||||
hasPinned: hasPinnedParam,
|
||||
paramType: paramType,
|
||||
constraints: constraints,
|
||||
property: property,
|
||||
@@ -794,10 +703,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
return
|
||||
}
|
||||
description, _ := descriptionWinner.Value.(string)
|
||||
interfaceDescription := ""
|
||||
if hasPinnedParam {
|
||||
interfaceDescription = strings.TrimSpace(pinnedParam.Description)
|
||||
}
|
||||
|
||||
// Required uses field-level safe merge: higher sources may raise required, but
|
||||
// Cobra MarkFlagRequired cannot be projected away as optional.
|
||||
@@ -838,9 +743,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
runtimeSchemaCandidate(true, true, "cobra_hard_required"),
|
||||
)
|
||||
}
|
||||
if interfaceDescription != "" && interfaceDescription != description {
|
||||
parameter.InterfaceDescription = interfaceDescription
|
||||
}
|
||||
if interfaceType != "" && interfaceType != paramType {
|
||||
parameter.InterfaceType = interfaceType
|
||||
fieldProvenance["interface_type"] = runtimeSchemaFieldProvenance(interfaceTypeWinner)
|
||||
@@ -855,12 +757,6 @@ func runtimeCommandParameterSpecs(cmd *cobra.Command, canonicalPath string, pinn
|
||||
if def := runtimeFlagDefault(flag); def != "" {
|
||||
parameter.Default = runtimeSchemaJSONString(def)
|
||||
}
|
||||
if hasPinnedParam {
|
||||
interfaceDefault := strings.TrimSpace(pinnedParam.Default)
|
||||
if interfaceDefault != "" && interfaceDefault != runtimeFlagDefault(flag) {
|
||||
parameter.InterfaceDefault = runtimeSchemaJSONString(interfaceDefault)
|
||||
}
|
||||
}
|
||||
formatWinner, ok := resolveField("format", fieldCtx.formatCandidates())
|
||||
if !ok {
|
||||
return
|
||||
@@ -991,19 +887,6 @@ func runtimeSchemaConstraintsEmpty(constraints RuntimeSchemaConstraints) bool {
|
||||
return runtimeannotate.ConstraintsEmpty(constraints)
|
||||
}
|
||||
|
||||
func lookupPinnedMCPParam(params map[string]embeddedMCPParamMeta, property, flagName string) (embeddedMCPParamMeta, bool) {
|
||||
if len(params) == 0 {
|
||||
return embeddedMCPParamMeta{}, false
|
||||
}
|
||||
if meta, ok := params[property]; ok {
|
||||
return meta, true
|
||||
}
|
||||
if meta, ok := params[flagName]; ok {
|
||||
return meta, true
|
||||
}
|
||||
return embeddedMCPParamMeta{}, false
|
||||
}
|
||||
|
||||
func isGenericPayloadFlag(flag *pflag.Flag) bool {
|
||||
if flag == nil {
|
||||
return false
|
||||
@@ -1162,69 +1045,87 @@ func strconvQuote(value string) string {
|
||||
|
||||
// ─── --compact mode ──────────────────────────────────────────────────────────
|
||||
|
||||
// schemaCompactStripKeys are top-level tool/product keys removed in --compact mode.
|
||||
var schemaCompactStripKeys = map[string]bool{
|
||||
// provenance / debug
|
||||
"agent_metadata_source": true,
|
||||
"agent_source_refs": true,
|
||||
"agent_summary_source": true,
|
||||
"effect_source": true,
|
||||
"metadata_source": true,
|
||||
"source": true,
|
||||
"agent_metadata": true,
|
||||
"field_provenance": true,
|
||||
"reviewed": true,
|
||||
// redundant with canonical_path / cli_path
|
||||
"name": true,
|
||||
"path": true,
|
||||
"cli_name": true,
|
||||
"primary_cli_path": true,
|
||||
"is_alias": true,
|
||||
"has_parameters": true,
|
||||
"parameter_count": true,
|
||||
"product_id": true,
|
||||
"display": true,
|
||||
"title": true,
|
||||
"group": true,
|
||||
"source_product_id": true,
|
||||
"aliases": true,
|
||||
"catalog_hash": true,
|
||||
"surface_hash": true,
|
||||
"workflow_refs": true,
|
||||
"prerequisites": true,
|
||||
"tips": true,
|
||||
"interface_ref": true,
|
||||
// schemaCompactPayloadKeys is the reviewed Agent-view allowlist. Keep this a
|
||||
// positive list: a new full/audit field must not silently expand routine Agent
|
||||
// context just because it was added to ToolSpec.ToPayload.
|
||||
var schemaCompactPayloadKeys = map[string]bool{
|
||||
// Navigation envelopes.
|
||||
"kind": true, "level": true, "count": true, "tool_count": true,
|
||||
"products": true, "product": true, "tools": true,
|
||||
"id": true, "schema_path": true, "runtime": true,
|
||||
// Leaf identity and execution semantics.
|
||||
"canonical_path": true, "cli_path": true,
|
||||
"agent_summary": true, "description": true,
|
||||
"effect": true, "risk": true, "confirmation": true, "idempotency": true,
|
||||
"interface_mode": true, "availability": true, "interface_reason": true,
|
||||
"parameters": true, "constraints": true, "positionals": true, "dry_run": true,
|
||||
"examples": true, "use_when": true, "avoid_when": true,
|
||||
}
|
||||
|
||||
// schemaCompactParamStripKeys are per-parameter keys removed in --compact mode.
|
||||
var schemaCompactParamStripKeys = map[string]bool{
|
||||
"interface_description": true,
|
||||
"interface_type": true,
|
||||
"property": true,
|
||||
"field_provenance": true,
|
||||
// schemaCompactParamKeys is the reviewed parameter allowlist for Agent command
|
||||
// construction. RPC mapping and provenance fields intentionally remain in the
|
||||
// full/audit view.
|
||||
var schemaCompactParamKeys = map[string]bool{
|
||||
"type": true, "description": true, "required": true,
|
||||
"cli_required": true, "required_when": true,
|
||||
"default": true, "interface_default": true, "example": true,
|
||||
"format": true, "enum": true,
|
||||
}
|
||||
|
||||
// stripSchemaPayloadCompact walks a schema payload map and removes provenance,
|
||||
// debug and redundant keys so that only agent-essential fields remain.
|
||||
// It operates recursively on nested maps, slices, and parameter objects.
|
||||
// stripSchemaPayloadCompact projects a full Schema payload onto the reviewed
|
||||
// Agent-view allowlist. Structural product/tool children are projected
|
||||
// recursively; constraint, positional and dry-run values are already typed
|
||||
// contract data and are retained verbatim.
|
||||
func stripSchemaPayloadCompact(payload map[string]any) map[string]any {
|
||||
if payload == nil {
|
||||
return nil
|
||||
}
|
||||
result := make(map[string]any, len(payload))
|
||||
for k, v := range payload {
|
||||
if schemaCompactStripKeys[k] {
|
||||
if !schemaCompactPayloadKeys[k] {
|
||||
continue
|
||||
}
|
||||
if k == "parameters" {
|
||||
switch k {
|
||||
case "parameters":
|
||||
result[k] = stripSchemaParametersCompact(v)
|
||||
continue
|
||||
case "product":
|
||||
if product, ok := v.(map[string]any); ok {
|
||||
result[k] = stripSchemaPayloadCompact(product)
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
case "products", "tools":
|
||||
result[k] = stripSchemaPayloadCollectionCompact(v)
|
||||
default:
|
||||
result[k] = v
|
||||
}
|
||||
result[k] = stripSchemaValueCompact(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stripSchemaPayloadCollectionCompact(value any) any {
|
||||
switch values := value.(type) {
|
||||
case []map[string]any:
|
||||
result := make([]map[string]any, len(values))
|
||||
for i, item := range values {
|
||||
result[i] = stripSchemaPayloadCompact(item)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(values))
|
||||
for i, item := range values {
|
||||
if payload, ok := item.(map[string]any); ok {
|
||||
result[i] = stripSchemaPayloadCompact(payload)
|
||||
} else {
|
||||
result[i] = item
|
||||
}
|
||||
}
|
||||
return result
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func stripSchemaParametersCompact(value any) any {
|
||||
parameters, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
@@ -1278,7 +1179,7 @@ func stripSchemaValueCompact(v any) any {
|
||||
func stripSchemaParamCompact(param map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(param))
|
||||
for k, v := range param {
|
||||
if schemaCompactParamStripKeys[k] {
|
||||
if !schemaCompactParamKeys[k] {
|
||||
continue
|
||||
}
|
||||
result[k] = v
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -63,39 +62,6 @@ func TestCrossPlatformCoverageCollectRuntimeSchemaEntriesErrorsAndOrdering(t *te
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeSchemaMetadataLookupEdges(t *testing.T) {
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}); ok {
|
||||
t.Fatal("empty lookup unexpectedly matched")
|
||||
}
|
||||
|
||||
leaf := &cobra.Command{Use: "reply"}
|
||||
contractfinal.RegisterRuntimeContractFinal(leaf, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "sample", Name: "run", CanonicalPath: "sample.run",
|
||||
CLIPath: "sample run", PrimaryCLIPath: "sample run",
|
||||
},
|
||||
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeMCP,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "chat", RPCName: "send_personal_message"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { ClearRuntimeContractFinalForTest(leaf) })
|
||||
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"clawType": {Type: "string"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{Command: leaf, ProductID: "chat", ToolName: "reply_personal_message"}, agentMetadata{}, mcp)
|
||||
if !ok || got.Parameters["clawType"].Type != "string" {
|
||||
t.Fatalf("ContractFinal Interface.Ref MCP remap = %#v ok=%v", got, ok)
|
||||
}
|
||||
if got.InterfaceRef == nil || got.InterfaceRef.RPCName != "send_personal_message" {
|
||||
t.Fatalf("InterfaceRef = %#v", got.InterfaceRef)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
value any
|
||||
want int
|
||||
@@ -155,13 +121,13 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
cmd.Flags().String("value", "", "value")
|
||||
flag := cmd.Flags().Lookup("value")
|
||||
|
||||
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
if specs, err := runtimeCommandParameterSpecs(nil, "sample.run", RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
t.Fatalf("nil command specs = %#v, err = %v", specs, err)
|
||||
}
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{}, errors.New("load failed")
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "load failed") {
|
||||
t.Fatalf("binding load error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
@@ -179,7 +145,7 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
MappingExclusions: map[string]string{"sample.run --value": " "},
|
||||
}, nil
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "mapping exclusion") {
|
||||
t.Fatalf("mapping exclusion error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
@@ -194,22 +160,22 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
}
|
||||
return resolveRuntimeSchemaCandidate(field, candidates...)
|
||||
})
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
|
||||
if _, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), target) {
|
||||
t.Fatalf("%s resolution error = %v", target, err)
|
||||
}
|
||||
}
|
||||
resolveRuntimeSchemaField = realResolver
|
||||
|
||||
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", nil, RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
if specs, err := runtimeCommandParameterSpecs(&cobra.Command{Use: "empty"}, "sample.empty", RuntimeSchemaConstraints{}); err != nil || specs != nil {
|
||||
t.Fatalf("empty specs = %#v, err = %v", specs, err)
|
||||
}
|
||||
if payload, err := runtimeCommandParameters(nil, "", nil, RuntimeSchemaConstraints{}); err != nil || payload != nil {
|
||||
if payload, err := runtimeCommandParameters(nil, "", RuntimeSchemaConstraints{}); err != nil || payload != nil {
|
||||
t.Fatalf("empty payload = %#v, err = %v", payload, err)
|
||||
}
|
||||
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
testseam.Swap(t, &runtimeCommandParameterSpecsForPayload, func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return []ParameterSpec{{Name: "bad", Example: json.RawMessage("{")}}, nil
|
||||
})
|
||||
if _, err := runtimeCommandParameters(cmd, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
|
||||
if _, err := runtimeCommandParameters(cmd, "sample.run", RuntimeSchemaConstraints{}); err == nil || !strings.Contains(err.Error(), "serialize Schema parameter") {
|
||||
t.Fatalf("payload serialization error = %v", err)
|
||||
}
|
||||
|
||||
@@ -218,32 +184,21 @@ func TestCrossPlatformCoverageRuntimeCommandParameterErrorEdges(t *testing.T) {
|
||||
t.Fatalf("required annotation = %v/%v", required, present)
|
||||
}
|
||||
|
||||
// Fixture MCP-shaped maps still participate when explicitly injected.
|
||||
// Binding snapshot still supplies reviewed property mappings without MCP pin.
|
||||
testseam.Swap(t, &schemaParameterBindingData, func() (schemaParameterBindingSnapshot, error) {
|
||||
return schemaParameterBindingSnapshot{
|
||||
Bindings: map[string]map[string]string{"sample.run": {"value": "clawType"}},
|
||||
}, nil
|
||||
})
|
||||
requiredTrue := true
|
||||
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", map[string]embeddedMCPParamMeta{
|
||||
"clawType": {
|
||||
Type: "string",
|
||||
Description: "fixture description",
|
||||
Required: &requiredTrue,
|
||||
Default: "fixture-default",
|
||||
},
|
||||
}, RuntimeSchemaConstraints{})
|
||||
specs, err := runtimeCommandParameterSpecs(cmd, "sample.run", RuntimeSchemaConstraints{})
|
||||
if err != nil {
|
||||
t.Fatalf("fixture pinned parameter specs error = %v", err)
|
||||
t.Fatalf("parameter specs error = %v", err)
|
||||
}
|
||||
if len(specs) != 1 || specs[0].Property != "clawType" || specs[0].InterfaceDescription != "fixture description" {
|
||||
t.Fatalf("fixture pinned parameter specs = %#v", specs)
|
||||
if len(specs) != 1 || specs[0].Property != "clawType" {
|
||||
t.Fatalf("parameter specs = %#v", specs)
|
||||
}
|
||||
if len(specs[0].InterfaceDefault) == 0 {
|
||||
t.Fatalf("fixture interface_default missing: %#v", specs[0])
|
||||
}
|
||||
if prov := specs[0].FieldProvenance["required"]; prov.Source == "" {
|
||||
t.Fatalf("fixture required provenance missing: %#v", specs[0].FieldProvenance)
|
||||
if prov := specs[0].FieldProvenance["property"]; prov.Source == "" {
|
||||
t.Fatalf("property provenance missing: %#v", specs[0].FieldProvenance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,9 +228,6 @@ func TestCrossPlatformCoverageRuntimeSchemaPureHelperEdges(t *testing.T) {
|
||||
if !reflect.DeepEqual(groups, [][]string{{"one"}}) {
|
||||
t.Fatalf("normalized groups = %#v", groups)
|
||||
}
|
||||
if meta, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"flag": {Type: "string"}}, "property", "flag"); !ok || meta.Type != "string" {
|
||||
t.Fatalf("flag fallback metadata = %#v/%v", meta, ok)
|
||||
}
|
||||
if isGenericPayloadFlag(nil) {
|
||||
t.Fatal("nil flag cannot be a generic payload")
|
||||
}
|
||||
@@ -322,4 +274,75 @@ func TestCrossPlatformCoverageSchemaCompactProjectionEdges(t *testing.T) {
|
||||
if _, exists := value["property"]; exists {
|
||||
t.Fatalf("compact parameter value = %#v", value)
|
||||
}
|
||||
// Non-parameter nested maps fall through to payload compacting.
|
||||
nested := stripSchemaValueCompact(map[string]any{"description": "keep", "provenance": "drop"}).(map[string]any)
|
||||
if nested["description"] != "keep" {
|
||||
t.Fatalf("nested non-param map = %#v", nested)
|
||||
}
|
||||
if _, exists := nested["provenance"]; exists {
|
||||
t.Fatalf("nested non-param provenance should drop: %#v", nested)
|
||||
}
|
||||
// Type-only maps still count as parameter objects.
|
||||
typedOnly := stripSchemaValueCompact(map[string]any{"type": "string", "property": "remote"}).(map[string]any)
|
||||
if _, exists := typedOnly["property"]; exists {
|
||||
t.Fatalf("type-only param value = %#v", typedOnly)
|
||||
}
|
||||
mapSlice := stripSchemaValueCompact([]map[string]any{{"description": "leaf", "provenance": "drop"}}).([]map[string]any)
|
||||
if len(mapSlice) != 1 || mapSlice[0]["description"] != "leaf" {
|
||||
t.Fatalf("value compact []map = %#v", mapSlice)
|
||||
}
|
||||
if _, exists := mapSlice[0]["provenance"]; exists {
|
||||
t.Fatalf("value compact []map provenance should drop: %#v", mapSlice)
|
||||
}
|
||||
anySlice := stripSchemaValueCompact([]any{map[string]any{"description": "leaf", "provenance": "drop"}, "raw"}).([]any)
|
||||
if len(anySlice) != 2 || anySlice[1] != "raw" {
|
||||
t.Fatalf("value compact []any = %#v", anySlice)
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"description": "calendar",
|
||||
"provenance": map[string]any{"source": "drop"},
|
||||
"parameters": parameters,
|
||||
"product": map[string]any{"description": "calendar", "provenance": "drop"},
|
||||
"products": []map[string]any{
|
||||
{"description": "calendar", "provenance": "drop"},
|
||||
},
|
||||
"tools": []any{
|
||||
map[string]any{"description": "leaf", "provenance": "drop"},
|
||||
"skip-me",
|
||||
},
|
||||
"constraints": map[string]any{"require_one_of": []any{}},
|
||||
}
|
||||
stripped := stripSchemaPayloadCompact(payload)
|
||||
if stripped["description"] != "calendar" {
|
||||
t.Fatalf("compact description = %#v", stripped["description"])
|
||||
}
|
||||
if _, exists := stripped["provenance"]; exists {
|
||||
t.Fatalf("compact should drop provenance: %#v", stripped)
|
||||
}
|
||||
if product, ok := stripped["product"].(map[string]any); !ok || product["description"] != "calendar" {
|
||||
t.Fatalf("compact product = %#v", stripped["product"])
|
||||
}
|
||||
if _, exists := stripped["product"].(map[string]any)["provenance"]; exists {
|
||||
t.Fatalf("nested product provenance should drop: %#v", stripped["product"])
|
||||
}
|
||||
if products, ok := stripped["products"].([]map[string]any); !ok || len(products) != 1 || products[0]["description"] != "calendar" {
|
||||
t.Fatalf("compact products = %#v", stripped["products"])
|
||||
}
|
||||
if tools, ok := stripped["tools"].([]any); !ok || len(tools) != 2 {
|
||||
t.Fatalf("compact tools = %#v", stripped["tools"])
|
||||
}
|
||||
if tool, ok := stripped["tools"].([]any)[0].(map[string]any); !ok || tool["description"] != "leaf" {
|
||||
t.Fatalf("compact tools[0] = %#v", stripped["tools"].([]any)[0])
|
||||
}
|
||||
if stripped["tools"].([]any)[1] != "skip-me" {
|
||||
t.Fatalf("compact tools[1] = %#v", stripped["tools"].([]any)[1])
|
||||
}
|
||||
// Non-map product values are retained verbatim.
|
||||
if got := stripSchemaPayloadCompact(map[string]any{"product": "raw"}); got["product"] != "raw" {
|
||||
t.Fatalf("non-map product = %#v", got["product"])
|
||||
}
|
||||
if got := stripSchemaPayloadCollectionCompact("raw"); got != "raw" {
|
||||
t.Fatalf("non-collection compact = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,25 +189,6 @@ func cloneFieldCandidates(source []contract.FieldCandidateProvenance) []contract
|
||||
return out
|
||||
}
|
||||
|
||||
func lookupAgentToolMetadataFrom(source agentMetadata, paths ...string) (agentToolMetadata, bool) {
|
||||
seen := map[string]bool{}
|
||||
for _, path := range paths {
|
||||
for _, candidate := range []string{
|
||||
strings.TrimSpace(path),
|
||||
strings.Join(splitSchemaPathTokens(path), " "),
|
||||
} {
|
||||
if candidate == "" || seen[candidate] {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = true
|
||||
if metadata, ok := source.Tools[candidate]; ok {
|
||||
return metadata, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return agentToolMetadata{}, false
|
||||
}
|
||||
|
||||
// agentMetadataSummaryFromProducts publishes Catalog-level Agent coverage from
|
||||
// the assembled Schema surface (ContractFinal / ProductDecl). This keeps
|
||||
// runtime delivery and CI dumps hash-aligned without requiring build-time
|
||||
|
||||
@@ -20,7 +20,6 @@ import (
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -94,9 +93,8 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
||||
// longer participates in assembly.
|
||||
root := buildRuntimeSchemaTestRoot()
|
||||
declareRuntimeSchemaTestRootDoc(t, root, nil)
|
||||
mcpFixture := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}
|
||||
|
||||
leaf, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
|
||||
leaf, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeSchemaPayloadForTest(leaf): %v", err)
|
||||
}
|
||||
@@ -110,7 +108,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
||||
t.Fatalf("leaf examples = %#v", leaf["examples"])
|
||||
}
|
||||
|
||||
catalog, err := runtimeSchemaPayloadForTestWithMetadata(root, nil, emptyAgentMetadata(), mcpFixture)
|
||||
catalog, err := runtimeSchemaPayloadForTest(root, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("runtimeSchemaPayloadForTest(catalog): %v", err)
|
||||
}
|
||||
@@ -136,7 +134,7 @@ func TestRuntimeSchemaIncludesAgentMetadata(t *testing.T) {
|
||||
t.Fatalf("product summary must not include examples: %#v", tools[0])
|
||||
}
|
||||
|
||||
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), mcpFixture)
|
||||
registry, err := schemaRegistryForTest(root)
|
||||
if err != nil {
|
||||
t.Fatalf("schemaRegistryForTest(): %v", err)
|
||||
}
|
||||
@@ -162,7 +160,7 @@ func TestRuntimeSchemaAllPayloadContainsFullLeafParameters(t *testing.T) {
|
||||
// exercises the production assembly path.
|
||||
root := buildRuntimeSchemaTestRoot()
|
||||
declareRuntimeSchemaTestRootDoc(t, root, nil)
|
||||
registry, err := schemaRegistryForTestWithMetadata(root, emptyAgentMetadata(), embeddedMCPMetadata{})
|
||||
registry, err := schemaRegistryForTest(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -203,9 +201,8 @@ func schemaTestInt(value any) int {
|
||||
}
|
||||
|
||||
func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
||||
// interface_ref declares on the leaf ContractFinal; the injected MCP
|
||||
// fixture participates through the gated fixture lookup (remapped via the
|
||||
// declared Interface.Ref).
|
||||
// interface_ref declares on the leaf ContractFinal; MCP pin is not a
|
||||
// parameter candidate source.
|
||||
root := buildRuntimeSchemaTestRoot()
|
||||
declareRuntimeSchemaTestRootDoc(t, root, func(payload *contract.ContractFinalPayload) {
|
||||
payload.Interface = &contract.InterfaceSpec{
|
||||
@@ -215,17 +212,8 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "documents", RPCName: "create_doc_v2"},
|
||||
}
|
||||
})
|
||||
mcpFixture := embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{
|
||||
"documents.create_doc_v2": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"title": {Description: "MCP document title"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"doc.create_document"}, emptyAgentMetadata(), mcpFixture)
|
||||
payload, err := runtimeSchemaPayloadForTest(root, []string{"doc.create_document"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -233,112 +221,11 @@ func TestRuntimeSchemaUsesVersionedInterfaceRef(t *testing.T) {
|
||||
if ref["product_id"] != "documents" || ref["rpc_name"] != "create_doc_v2" {
|
||||
t.Fatalf("interface_ref = %#v", payload["interface_ref"])
|
||||
}
|
||||
parameters, _ := payload["parameters"].(map[string]any)
|
||||
title, _ := parameters["title"].(map[string]any)
|
||||
if title["interface_description"] != "MCP document title" {
|
||||
t.Fatalf("title metadata = %#v", title)
|
||||
if payload["interface_mode"] != contract.InterfaceModeMCP {
|
||||
t.Fatalf("interface_mode = %#v", payload["interface_mode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPRequiredParticipatesInSourcePrecedence(t *testing.T) {
|
||||
required := true
|
||||
mcpFixture := embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{
|
||||
"sample.list_items": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"limit": {Required: &required},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
list.Flags().Int("limit", 0, "optional page size")
|
||||
AttachRuntimeSchema(list, "sample", "list_items", "test")
|
||||
sample := &cobra.Command{Use: "sample"}
|
||||
sample.AddCommand(list)
|
||||
root.AddCommand(sample)
|
||||
declareSampleListItemsLeaf(t, list)
|
||||
|
||||
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parameters, _ := payload["parameters"].(map[string]any)
|
||||
limit, _ := parameters["limit"].(map[string]any)
|
||||
if limit["required"] != true {
|
||||
t.Fatalf("MCP required candidate did not win over the default: %#v", limit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPDefaultDoesNotOverrideCLIDefault(t *testing.T) {
|
||||
mcpFixture := embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{
|
||||
"sample.list_items": {
|
||||
Parameters: map[string]embeddedMCPParamMeta{
|
||||
"limit": {Default: "50"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
list := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
|
||||
list.Flags().Int("limit", 10, "optional page size")
|
||||
AttachRuntimeSchema(list, "sample", "list_items", "test")
|
||||
sample := &cobra.Command{Use: "sample"}
|
||||
sample.AddCommand(list)
|
||||
root.AddCommand(sample)
|
||||
declareSampleListItemsLeaf(t, list)
|
||||
|
||||
payload, err := runtimeSchemaPayloadForTestWithMetadata(root, []string{"sample.list_items"}, emptyAgentMetadata(), mcpFixture)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parameters, _ := payload["parameters"].(map[string]any)
|
||||
limit, _ := parameters["limit"].(map[string]any)
|
||||
if limit["default"] != "10" || limit["interface_default"] != "50" {
|
||||
t.Fatalf("CLI and interface defaults were not separated: %#v", limit)
|
||||
}
|
||||
}
|
||||
|
||||
// declareSampleListItemsLeaf registers the ContractFinal / ProductDecl
|
||||
// declarations for the synthetic sample.list_items leaf so MCP fixture tests
|
||||
// assemble through the production path.
|
||||
func declareSampleListItemsLeaf(t *testing.T, list *cobra.Command) {
|
||||
t.Helper()
|
||||
contractfinal.RegisterRuntimeContractFinal(list, contract.ContractFinalPayload{
|
||||
Identity: &contract.ToolIdentitySpec{
|
||||
ProductID: "sample", Name: "list_items", CanonicalPath: "sample.list_items",
|
||||
CLIPath: "sample list", PrimaryCLIPath: "sample list",
|
||||
},
|
||||
Title: "List items",
|
||||
Description: "List sample items",
|
||||
Safety: &contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local", Availability: "available", Reason: "test local leaf",
|
||||
},
|
||||
Selection: &contract.SelectionSpec{
|
||||
AgentSummary: "List sample items",
|
||||
UseWhen: []string{"list sample items"},
|
||||
AvoidWhen: []string{"not listing"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { contractfinal.ClearRuntimeContractFinalForTest(list) })
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "sample",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "Sample product",
|
||||
UseWhen: []string{"sample routing"},
|
||||
AvoidWhen: []string{"not sample"},
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { contract.ClearProductDeclForTest("sample") })
|
||||
}
|
||||
|
||||
func findSchemaProduct(products []map[string]any, id string) map[string]any {
|
||||
for _, product := range products {
|
||||
if product["id"] == id {
|
||||
|
||||
@@ -508,6 +508,10 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leaf["future_audit_field"] = "must not leak into Agent view"
|
||||
for _, raw := range schemaMap(leaf["parameters"]) {
|
||||
raw["future_mapping_field"] = "must not leak into Agent view"
|
||||
}
|
||||
stripped := stripSchemaPayloadCompact(leaf)
|
||||
|
||||
// Must keep agent-essential fields.
|
||||
@@ -518,7 +522,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
||||
}
|
||||
|
||||
// Must strip provenance / redundant fields.
|
||||
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display"} {
|
||||
for _, key := range []string{"agent_metadata_source", "agent_source_refs", "agent_summary_source", "effect_source", "metadata_source", "primary_cli_path", "parameter_count", "has_parameters", "interface_ref", "source", "title", "display", "future_audit_field"} {
|
||||
if _, ok := stripped[key]; ok {
|
||||
t.Fatalf("compact leaf still contains stripped key %q", key)
|
||||
}
|
||||
@@ -528,7 +532,7 @@ func TestStripSchemaPayloadCompactLeaf(t *testing.T) {
|
||||
if params, ok := stripped["parameters"].(map[string]any); ok {
|
||||
for name, p := range params {
|
||||
if pm, ok := p.(map[string]any); ok {
|
||||
for _, stripped := range []string{"interface_description", "interface_type", "property"} {
|
||||
for _, stripped := range []string{"interface_description", "interface_type", "property", "future_mapping_field"} {
|
||||
if _, present := pm[stripped]; present {
|
||||
t.Fatalf("compact param %q still contains %q", name, stripped)
|
||||
}
|
||||
@@ -1304,7 +1308,7 @@ func TestDeliveryCatalogContactParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
}
|
||||
if want.interfaceType != "" {
|
||||
prov := schemaMap(param["field_provenance"])["interface_type"]
|
||||
if src, _ := prov["source"].(string); src != "native_annotation" && src != "mcp_metadata" {
|
||||
if src, _ := prov["source"].(string); src != "native_annotation" {
|
||||
t.Fatalf("%s --%s interface_type source = %#v", tc.path, flagName, prov)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ package cli
|
||||
var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
{
|
||||
ID: "cli-management",
|
||||
Reason: "Local CLI lifecycle, authentication, configuration, recovery, and plugin-management commands are user-operated controls rather than stable Agent tools.",
|
||||
Reason: "Local CLI lifecycle, authentication, configuration, and plugin-management commands are user-operated controls rather than stable Agent tools.",
|
||||
Reviewed: true,
|
||||
Commands: []string{
|
||||
"api",
|
||||
@@ -53,9 +53,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"profile list",
|
||||
"profile switch",
|
||||
"profile use",
|
||||
"recovery execute",
|
||||
"recovery finalize",
|
||||
"recovery plan",
|
||||
"schema",
|
||||
"skill get",
|
||||
"skill install",
|
||||
|
||||
@@ -137,8 +137,8 @@ func schemaToolSpecFromPayload(payload map[string]any) (ToolSpec, error) {
|
||||
|
||||
// runtimeCommandParameters is the compatibility wire adapter used only by
|
||||
// tests; resolution happens in runtimeCommandParameterSpecs.
|
||||
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, pinnedParams map[string]embeddedMCPParamMeta, constraints RuntimeSchemaConstraints) (map[string]any, error) {
|
||||
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, pinnedParams, constraints)
|
||||
func runtimeCommandParameters(cmd *cobra.Command, canonicalPath string, constraints RuntimeSchemaConstraints) (map[string]any, error) {
|
||||
specs, err := runtimeCommandParameterSpecsForPayload(cmd, canonicalPath, constraints)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -969,16 +969,16 @@ func TestOverallCoverageGapDeliveryCompletenessAndDryRun(t *testing.T) {
|
||||
func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
|
||||
prevSpecs := runtimeCommandParameterSpecsForPayload
|
||||
t.Cleanup(func() { runtimeCommandParameterSpecsForPayload = prevSpecs })
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return nil, fmt.Errorf("specs boom")
|
||||
}
|
||||
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{}); err == nil {
|
||||
if _, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{}); err == nil {
|
||||
t.Fatal("parameter specs error must surface")
|
||||
}
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, map[string]embeddedMCPParamMeta, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
runtimeCommandParameterSpecsForPayload = func(*cobra.Command, string, RuntimeSchemaConstraints) ([]ParameterSpec, error) {
|
||||
return []ParameterSpec{{Name: "ok", Type: "string"}}, nil
|
||||
}
|
||||
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", nil, RuntimeSchemaConstraints{})
|
||||
payload, err := runtimeCommandParameters(&cobra.Command{Use: "run"}, "sample.run", RuntimeSchemaConstraints{})
|
||||
if err != nil || payload["ok"] == nil {
|
||||
t.Fatalf("parameter payload = %#v err=%v", payload, err)
|
||||
}
|
||||
@@ -1070,24 +1070,6 @@ func TestOverallCoverageGapRuntimeParamsAndAgentMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOverallRegressionRecovery(t *testing.T) {
|
||||
if _, ok := lookupPinnedMCPParam(nil, "property", "flag"); ok {
|
||||
t.Fatal("nil pinned params must miss")
|
||||
}
|
||||
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{}, "property", "flag"); ok {
|
||||
t.Fatal("empty pinned params must miss")
|
||||
}
|
||||
if _, ok := lookupPinnedMCPParam(map[string]embeddedMCPParamMeta{"other": {Type: "string"}}, "property", "flag"); ok {
|
||||
t.Fatal("unmatched pinned params must miss")
|
||||
}
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{}); ok {
|
||||
t.Fatal("empty MCP metadata must not match")
|
||||
}
|
||||
if _, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{}, agentMetadata{}, embeddedMCPMetadata{
|
||||
Tools: map[string]embeddedMCPToolMetadata{"other.key": {}},
|
||||
}); ok {
|
||||
t.Fatal("missing MCP metadata keys must not match")
|
||||
}
|
||||
|
||||
left := runtimeSchemaStringCandidateAtPriority("same", true, "z-source", 5, "p")
|
||||
right := runtimeSchemaStringCandidateAtPriority("same", true, "a-source", 5, "p")
|
||||
winner, err := resolveRuntimeSchemaCandidate("source-order", left, right)
|
||||
|
||||
@@ -410,7 +410,7 @@ func TestRuntimeCommandParameterSpecsPreserveReviewedEmptyPropertyProvenance(t *
|
||||
cmd := &cobra.Command{Use: "query"}
|
||||
cmd.Flags().Bool("all", false, "fetch every page")
|
||||
|
||||
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", nil, RuntimeSchemaConstraints{})
|
||||
parameters, err := runtimeCommandParameterSpecs(cmd, "aitable.query_records", RuntimeSchemaConstraints{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"drive.download_file --output": "local output path",
|
||||
"drive.download_file --parallel": "local multipart download control; never sent to download_file",
|
||||
"drive.download_file --part-size": "local multipart download control; never sent to download_file",
|
||||
"drive.download_file --version": "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; not a download_file interface property",
|
||||
"drive.download_file_version --no-resume": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --no-resume is a CLI-local multipart download control and does not publish a direct interface property.",
|
||||
"drive.download_file_version --node": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.download_file_version --output": "Reviewed unpinned adapter: drive.download_file_version has no singular pinned interface_ref; --output is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -655,7 +656,6 @@ var reviewedSchemaParameterBindingRemovals = map[string]schemaParameterBindingRe
|
||||
"contact.get_dept_info_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"contact.get_dept_members_by_deptId --ids": {Reason: "The public flag was renamed from --ids to the unambiguous --depts spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"contact.get_sub_depts_by_dept_id --id": {Reason: "The public flag was renamed from --id to the unambiguous --dept spelling; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"drive.download_file --version": {Reason: "Polymorphic dispatch: --version switches the MCP tool call from download_file to download_file_version; the version property belongs to download_file_version metadata, not download_file.", Reviewed: true},
|
||||
"minutes.query_user_tag_list --limit": {Reason: "The current helper and pinned interface have no pagination input.", Reviewed: true},
|
||||
"oa.list_pending_approvals --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
"oa.list_user_visible_process --size": {Reason: "The public pagination flag was normalized from --size to --limit; successor binding retired to ParamDecl.Property on the owning leaf (Track 1 Phase 2).", Reviewed: true},
|
||||
|
||||
@@ -70,18 +70,6 @@ func schemaRegistryForTest(root *cobra.Command) (SchemaRegistry, error) {
|
||||
return AssembleSchemaRegistryFromBound(bound)
|
||||
}
|
||||
|
||||
func schemaRegistryForTestWithMetadata(root *cobra.Command, agent agentMetadata, mcp embeddedMCPMetadata) (SchemaRegistry, error) {
|
||||
bound, err := boundTestCommandRegistry(root)
|
||||
if err != nil {
|
||||
return SchemaRegistry{}, err
|
||||
}
|
||||
// Production-shaped assembly: leaves must carry ContractFinal and products
|
||||
// a ProductDecl (see declareRuntimeSchemaTestRootDoc). Injected MCP/agent
|
||||
// fixtures participate only through the gated fixture lookup in
|
||||
// runtimeToolSpecFromContractFinal; production passes an empty pin.
|
||||
return assembleSchemaRegistryFromBound(bound, runtimeSchemaMetadataSources{Agent: agent, MCP: mcp})
|
||||
}
|
||||
|
||||
// declareRuntimeSchemaTestRootDoc registers the ContractFinal / ProductDecl
|
||||
// declarations for the synthetic doc.create_document tree built by
|
||||
// buildRuntimeSchemaTestRoot, so production-shaped assembly can resolve it.
|
||||
@@ -156,18 +144,6 @@ func runtimeSchemaPayloadForTest(root *cobra.Command, args []string) (map[string
|
||||
return schemaPayloadFromLoadedCatalog(loaded, args)
|
||||
}
|
||||
|
||||
func runtimeSchemaPayloadForTestWithMetadata(root *cobra.Command, args []string, agent agentMetadata, mcp embeddedMCPMetadata) (map[string]any, error) {
|
||||
registry, err := schemaRegistryForTestWithMetadata(root, agent, mcp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loaded, err := loadedSchemaCatalogForTestRegistry(registry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return schemaPayloadFromLoadedCatalog(loaded, args)
|
||||
}
|
||||
|
||||
func runtimeSchemaAllPayloadForTest(root *cobra.Command) (map[string]any, error) {
|
||||
registry, err := schemaRegistryForTest(root)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,8 +15,10 @@ import (
|
||||
)
|
||||
|
||||
type runtimeSchemaMetadataSources struct {
|
||||
// Agent remains only for historical test seams that still construct this
|
||||
// struct; production assembly does not overlay Agent or MCP pin onto
|
||||
// parameters or tool text.
|
||||
Agent agentMetadata
|
||||
MCP embeddedMCPMetadata
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -62,10 +64,9 @@ func (resolved ResolvedSchemaBuild) CommandCount() int {
|
||||
}
|
||||
|
||||
func pinnedRuntimeSchemaMetadataSources() runtimeSchemaMetadataSources {
|
||||
return runtimeSchemaMetadataSources{
|
||||
Agent: runtimeAgentMetadata(),
|
||||
MCP: emptyPinnedMCPMetadata(),
|
||||
}
|
||||
// Production pin and Agent inject are both retired; assembly is Contract /
|
||||
// ParamDecl / Cobra only.
|
||||
return runtimeSchemaMetadataSources{}
|
||||
}
|
||||
|
||||
// ResolveSchemaBuild is the only assembly path from executable Cobra commands
|
||||
@@ -123,7 +124,7 @@ func AssembleSchemaRegistryFromBound(bound BoundCommandRegistry) (SchemaRegistry
|
||||
|
||||
// assembleSchemaRegistryFromBound resolves every entry through the
|
||||
// ContractFinal / ProductDecl production path. Missing declarations fail
|
||||
// closed; retired skill/MCP/agent-inject overlays are never reopened.
|
||||
// closed; retired skill/MCP-pin/agent-inject overlays are never reopened.
|
||||
func assembleSchemaRegistryFromBound(bound BoundCommandRegistry, metadata runtimeSchemaMetadataSources) (SchemaRegistry, error) {
|
||||
entries, err := assembleCollectEntries(bound)
|
||||
if err != nil {
|
||||
@@ -197,18 +198,12 @@ func assembleProductSelection(entry runtimeSchemaEntry) (contract.SelectionSpec,
|
||||
|
||||
// runtimeToolSpecFromContractFinal pass-throughs Contract-authored Schema fields.
|
||||
// Declared values are the final data source; hints/registry text does not merge.
|
||||
// Production MCP pin is empty, so assembly skips MCP-metadata lookups entirely;
|
||||
// interface_type / interface_* facts come from ParamDecl / native annotations.
|
||||
// Tests may still inject a non-empty MCP fixture map, which participates through
|
||||
// pinnedMCPMetadataForEntryFrom.
|
||||
// MCP pin is retired: interface_type / interface_* facts come from ParamDecl /
|
||||
// native annotations only.
|
||||
func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.ContractFinalPayload, metadata runtimeSchemaMetadataSources) (ToolSpec, error) {
|
||||
_ = metadata // reserved for historical assemble seams; no overlay sources remain
|
||||
canonicalPath := entry.ProductID + "." + entry.ToolName
|
||||
constraints := runtimeCommandConstraints(entry.Command)
|
||||
var pinnedParams map[string]embeddedMCPParamMeta
|
||||
if len(metadata.MCP.Tools) > 0 {
|
||||
pinnedMeta, _ := pinnedMCPMetadataForEntryFrom(entry, metadata.Agent, metadata.MCP)
|
||||
pinnedParams = pinnedMeta.Parameters
|
||||
}
|
||||
// Apply parameter declarations from the contract.ContractFinalPayload before the
|
||||
// resolver reads them. The decls were put there by AttachContract at
|
||||
// DeclareLeafMetadata time; now that all flags exist on the fully-built
|
||||
@@ -216,7 +211,7 @@ func runtimeToolSpecFromContractFinal(entry runtimeSchemaEntry, final contract.C
|
||||
if err := ApplyParamDecls(entry.Command, final.Parameters); err != nil {
|
||||
return ToolSpec{}, fmt.Errorf("apply Contract Schema ParamDecls for %s: %w", canonicalPath, err)
|
||||
}
|
||||
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, pinnedParams, constraints)
|
||||
parameters, err := resolveRuntimeParameters(entry.Command, canonicalPath, constraints)
|
||||
if err != nil {
|
||||
return ToolSpec{}, fmt.Errorf("resolve Contract Schema parameters for %s: %w", canonicalPath, err)
|
||||
}
|
||||
|
||||
@@ -278,23 +278,10 @@ func TestCrossPlatformCoverageRenderSafetyAnnotationSuccess(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMCPMetadataInterfaceRefEdges(t *testing.T) {
|
||||
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{}, " ", " "); ok {
|
||||
t.Fatal("blank interface ref must miss")
|
||||
}
|
||||
if _, ok := mcpMetadataForInterfaceRef(embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{}}, "chat", "missing"); ok {
|
||||
t.Fatal("missing MCP tool must miss")
|
||||
}
|
||||
agent := agentMetadata{Tools: map[string]agentToolMetadata{
|
||||
"chat reply": {InterfaceRef: &embeddedMCPInterfaceRef{ProductID: "chat", RPCName: "send_personal_message"}},
|
||||
}}
|
||||
mcp := embeddedMCPMetadata{Tools: map[string]embeddedMCPToolMetadata{
|
||||
"chat.send_personal_message": {Parameters: map[string]embeddedMCPParamMeta{"clawType": {Type: "string"}}},
|
||||
}}
|
||||
got, ok := pinnedMCPMetadataForEntryFrom(runtimeSchemaEntry{
|
||||
PrimaryCLIPath: "chat reply", ProductID: "chat", ToolName: "reply_personal_message",
|
||||
}, agent, mcp)
|
||||
if !ok || got.Parameters["clawType"].Type != "string" {
|
||||
t.Fatalf("agent InterfaceRef remap = %#v ok=%v", got, ok)
|
||||
// MCP pin lookup helpers are retired; keep this named coverage slot as a
|
||||
// no-op marker so CrossPlatformCoverage* selection stays stable.
|
||||
if got := emptyPinnedMCPMetadata(); got.Tools == nil || len(got.Tools) != 0 {
|
||||
t.Fatalf("empty pinned metadata = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Command cmd_command_path_fallbacks validates the reviewed recovery-only
|
||||
// command path table against the live distribution Cobra tree and writes the
|
||||
// deterministic runtime lookup source. It does not add paths to Cobra, Help,
|
||||
// Skill, Schema, or the stable CommandRegistry identity surface.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"go/format"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/generator/outputguard"
|
||||
)
|
||||
|
||||
const defaultOutput = "internal/cli/command_path_fallbacks_generated.go"
|
||||
|
||||
var (
|
||||
newCommandFallbackRoot = app.NewSchemaSourceRootCommand
|
||||
reduceCommandFallbackEntries = cli.ReduceCommandPathFallbacks
|
||||
formatCommandFallbackSource = format.Source
|
||||
writeCommandFallbackFile = os.WriteFile
|
||||
exitCommandFallbackProcess = os.Exit
|
||||
)
|
||||
|
||||
func main() {
|
||||
var rootPath string
|
||||
var outputPath string
|
||||
flag.StringVar(&rootPath, "root", ".", "Repository root used to protect generator inputs")
|
||||
flag.StringVar(&outputPath, "output", defaultOutput, "Output generated command-path fallback table")
|
||||
flag.Parse()
|
||||
|
||||
if err := generateCommandPathFallbacks(rootPath, outputPath); err != nil {
|
||||
fail(err)
|
||||
}
|
||||
}
|
||||
|
||||
func generateCommandPathFallbacks(rootPath, outputPath string) error {
|
||||
if err := validateOutputIsolation(rootPath, outputPath); err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := reduceCommandFallbackEntries(newCommandFallbackRoot())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
source, err := renderCommandPathFallbacks(entries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeCommandFallbackFile(outputPath, source, 0o644); err != nil {
|
||||
return fmt.Errorf("write generated command path fallbacks: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(os.Stderr, "generated command path fallbacks: output=%s entries=%d\n", outputPath, len(entries))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOutputIsolation(rootPath, outputPath string) error {
|
||||
inputs := []outputguard.Input{
|
||||
{Name: "reviewed command path fallbacks", Path: "internal/cli/command_path_fallbacks.json"},
|
||||
{Name: "reviewed command path fallback schema", Path: "internal/cli/command_path_fallbacks.schema.json"},
|
||||
}
|
||||
target := outputguard.Target{Name: "--output", Path: outputPath}
|
||||
if err := outputguard.Validate(rootPath, inputs, []outputguard.Target{target}); err != nil {
|
||||
return err
|
||||
}
|
||||
return outputguard.ValidateRepoTargetAllowlist(rootPath, target, defaultOutput)
|
||||
}
|
||||
|
||||
func renderCommandPathFallbacks(entries []cli.CommandPathFallback) ([]byte, error) {
|
||||
var b bytes.Buffer
|
||||
b.WriteString("// Copyright 2026 Alibaba Group\n")
|
||||
b.WriteString("// Licensed under the Apache License, Version 2.0\n\n")
|
||||
b.WriteString("// Code generated by cmd_command_path_fallbacks; DO NOT EDIT.\n")
|
||||
b.WriteString("// Source: internal/cli/command_path_fallbacks.json validated against the live Cobra tree.\n")
|
||||
b.WriteString("// Regenerate with `make generate-schema` (or `go generate ./internal/cli`).\n\n")
|
||||
b.WriteString("package cli\n\n")
|
||||
b.WriteString("var generatedCommandPathFallbacks = []CommandPathFallback{\n")
|
||||
for _, entry := range entries {
|
||||
b.WriteString("\t{\n")
|
||||
fmt.Fprintf(&b, "\t\tFrom: %q,\n", entry.From)
|
||||
fmt.Fprintf(&b, "\t\tMode: %q,\n", entry.Mode)
|
||||
if entry.To != "" {
|
||||
fmt.Fprintf(&b, "\t\tTo: %q,\n", entry.To)
|
||||
}
|
||||
if len(entry.Candidates) > 0 {
|
||||
fmt.Fprintf(&b, "\t\tCandidates: %s,\n", renderStringSlice(entry.Candidates))
|
||||
}
|
||||
b.WriteString("\t\tReviewed: true,\n")
|
||||
fmt.Fprintf(&b, "\t\tReviewReason: %q,\n", entry.ReviewReason)
|
||||
b.WriteString("\t},\n")
|
||||
}
|
||||
b.WriteString("}\n\n")
|
||||
b.WriteString("func loadGeneratedCommandPathFallbacks() []CommandPathFallback {\n")
|
||||
b.WriteString("\treturn generatedCommandPathFallbacks\n")
|
||||
b.WriteString("}\n")
|
||||
|
||||
formatted, err := formatCommandFallbackSource(b.Bytes())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("format generated command path fallbacks: %w", err)
|
||||
}
|
||||
return formatted, nil
|
||||
}
|
||||
|
||||
func renderStringSlice(values []string) string {
|
||||
quoted := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
quoted[index] = fmt.Sprintf("%q", value)
|
||||
}
|
||||
return "[]string{" + strings.Join(quoted, ", ") + "}"
|
||||
}
|
||||
|
||||
func fail(err error) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
exitCommandFallbackProcess(1)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackGeneratorUsesDistributionOwnedCommandTree(t *testing.T) {
|
||||
got := reflect.ValueOf(newCommandFallbackRoot).Pointer()
|
||||
want := reflect.ValueOf(app.NewSchemaSourceRootCommand).Pointer()
|
||||
if got != want {
|
||||
t.Fatal("command fallback generator must use the distribution-owned Schema source command tree")
|
||||
}
|
||||
}
|
||||
|
||||
func preserveCommandFallbackGeneratorGlobals(t *testing.T) {
|
||||
t.Helper()
|
||||
oldArgs, oldFlags := os.Args, flag.CommandLine
|
||||
oldRoot := newCommandFallbackRoot
|
||||
oldReduce := reduceCommandFallbackEntries
|
||||
oldFormat := formatCommandFallbackSource
|
||||
oldWrite := writeCommandFallbackFile
|
||||
oldExit := exitCommandFallbackProcess
|
||||
t.Cleanup(func() {
|
||||
os.Args, flag.CommandLine = oldArgs, oldFlags
|
||||
newCommandFallbackRoot = oldRoot
|
||||
reduceCommandFallbackEntries = oldReduce
|
||||
formatCommandFallbackSource = oldFormat
|
||||
writeCommandFallbackFile = oldWrite
|
||||
exitCommandFallbackProcess = oldExit
|
||||
})
|
||||
}
|
||||
|
||||
func commandFallbackRepositoryRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
root, err := filepath.Abs(filepath.Join("..", "..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackMainWritesGeneratedFile(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go")
|
||||
flag.CommandLine = flag.NewFlagSet("command-fallback-success", flag.ContinueOnError)
|
||||
os.Args = []string{"cmd_command_path_fallbacks", "-root", commandFallbackRepositoryRoot(t), "-output", output}
|
||||
newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
|
||||
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) {
|
||||
return []cli.CommandPathFallback{{
|
||||
From: "demo +bad",
|
||||
Mode: cli.CommandPathFallbackRewrite,
|
||||
To: "demo +good",
|
||||
Reviewed: true,
|
||||
ReviewReason: "fixture",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
main()
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{
|
||||
"func loadGeneratedCommandPathFallbacks() []CommandPathFallback",
|
||||
`From: "demo +bad"`,
|
||||
`To: "demo +good"`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("generated source missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGenerateCommandPathFallbackFailurePaths(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
root := commandFallbackRepositoryRoot(t)
|
||||
output := filepath.Join(t.TempDir(), "command_path_fallbacks_generated.go")
|
||||
newCommandFallbackRoot = func(...context.Context) *cobra.Command { return &cobra.Command{Use: "dws"} }
|
||||
|
||||
if err := generateCommandPathFallbacks(root, filepath.Join(root, "internal", "cli", "command_path_fallbacks.json")); err == nil {
|
||||
t.Fatal("generator accepted output overlapping reviewed input")
|
||||
}
|
||||
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) {
|
||||
return nil, errors.New("reduce")
|
||||
}
|
||||
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "reduce") {
|
||||
t.Fatalf("reduction error = %v", err)
|
||||
}
|
||||
reduceCommandFallbackEntries = func(*cobra.Command) ([]cli.CommandPathFallback, error) { return nil, nil }
|
||||
formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("format") }
|
||||
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "format generated") {
|
||||
t.Fatalf("format error = %v", err)
|
||||
}
|
||||
formatCommandFallbackSource = format.Source
|
||||
writeCommandFallbackFile = func(string, []byte, os.FileMode) error { return errors.New("write") }
|
||||
if err := generateCommandPathFallbacks(root, output); err == nil || !strings.Contains(err.Error(), "write generated") {
|
||||
t.Fatalf("write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateCommandFallbackOutputIsolation(t *testing.T) {
|
||||
root := commandFallbackRepositoryRoot(t)
|
||||
if err := validateOutputIsolation(root, filepath.Join(t.TempDir(), "fallbacks.go")); err != nil {
|
||||
t.Fatalf("temporary output rejected: %v", err)
|
||||
}
|
||||
if err := validateOutputIsolation(root, filepath.Join(root, "internal", "cli", "not_a_delivery_target.go")); err == nil ||
|
||||
!strings.Contains(err.Error(), "not a canonical generated delivery target") {
|
||||
t.Fatalf("non-canonical repository output error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRenderCommandPathFallbacksShape(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
entries := []cli.CommandPathFallback{
|
||||
{
|
||||
From: "demo +bad",
|
||||
Mode: cli.CommandPathFallbackRewrite,
|
||||
To: "demo +good",
|
||||
Reviewed: true,
|
||||
ReviewReason: "rewrite fixture",
|
||||
},
|
||||
{
|
||||
From: "demo +choose",
|
||||
Mode: cli.CommandPathFallbackAmbiguous,
|
||||
Candidates: []string{"demo +one", "demo +two"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "ambiguous fixture",
|
||||
},
|
||||
}
|
||||
data, err := renderCommandPathFallbacks(entries)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
for _, want := range []string{
|
||||
`Mode: "rewrite"`,
|
||||
`Candidates: []string{"demo +one", "demo +two"}`,
|
||||
`ReviewReason: "ambiguous fixture"`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("render missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if got := renderStringSlice(nil); got != "[]string{}" {
|
||||
t.Fatalf("renderStringSlice(nil) = %q", got)
|
||||
}
|
||||
formatCommandFallbackSource = func([]byte) ([]byte, error) { return nil, errors.New("broken formatter") }
|
||||
if _, err := renderCommandPathFallbacks(entries); err == nil || !strings.Contains(err.Error(), "broken formatter") {
|
||||
t.Fatalf("formatter error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackMainReportsFailure(t *testing.T) {
|
||||
preserveCommandFallbackGeneratorGlobals(t)
|
||||
flag.CommandLine = flag.NewFlagSet("command-fallback-failure", flag.ContinueOnError)
|
||||
os.Args = []string{
|
||||
"cmd_command_path_fallbacks",
|
||||
"-root", commandFallbackRepositoryRoot(t),
|
||||
"-output", filepath.Join(commandFallbackRepositoryRoot(t), "internal", "cli", "command_path_fallbacks.json"),
|
||||
}
|
||||
exitCommandFallbackProcess = func(code int) { panic(code) }
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != 1 {
|
||||
t.Fatalf("main exit = %#v, want 1", recovered)
|
||||
}
|
||||
}()
|
||||
main()
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func newCalendarCommand() *cobra.Command {
|
||||
Long: `管理钉钉日历:日程、参会人、会议室、闲忙、附件、日历本、访问权限。调用前必须先使用 --help 查看参数结构。
|
||||
|
||||
命令结构:
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond] 日程管理
|
||||
dws calendar event [list|get|create|update|delete|suggest|respond|instances] 日程管理
|
||||
dws calendar attendee [list|add|delete] 参会人管理
|
||||
dws calendar room [search|add|delete|list-groups] 会议室管理
|
||||
dws calendar busy search 闲忙查询 (可查人、查会议室)
|
||||
@@ -2082,7 +2082,184 @@ func newCalendarCommand() *cobra.Command {
|
||||
eventSuggestCmd.Flags().String("members", "", "")
|
||||
_ = eventSuggestCmd.Flags().MarkHidden("members")
|
||||
eventSuggestCmd.Flags().String("duration", "", "日程持续时间 (分钟,默认30)")
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd)
|
||||
|
||||
eventInstancesCmd := &cobra.Command{
|
||||
Use: "instances",
|
||||
Short: "查询循环日程的实例列表",
|
||||
Long: `查询指定重复性日程(SeriesMaster)在指定时间范围内的所有实例。
|
||||
**注意**:此接口只能查询重复性日程的实例;若传入的是普通非循环日程,将查不到任何实例信息。
|
||||
必须传入 --id 指定重复性日程的 eventId(即 SeriesMaster 的 eventId,可通过 event list 获取)。
|
||||
不传 --start/--end 时,默认查询今天(00:00:00 ~ 23:59:59)的实例。`,
|
||||
Example: ` dws calendar event instances --id EVENT_ID
|
||||
dws calendar event instances --id EVENT_ID --start "2026-03-10T00:00:00+08:00" --end "2026-03-31T23:59:59+08:00"
|
||||
dws calendar event instances --id EVENT_ID --limit 50
|
||||
dws calendar event instances --id EVENT_ID --cursor "<nextCursor>"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
eventID, err := mustFlagOrFallback(cmd, "id", "event", "event-id", "eventId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"eventId": eventID}
|
||||
var startTime, endTime int64
|
||||
var now time.Time
|
||||
if v := flagOrFallback(cmd, "start", "time-min", "min-time", "start-time", "startTime", "start_time", "start-date", "startDate"); v != "" {
|
||||
startTime, err = parseISOTimeToMillis("start", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["startTime"] = startTime
|
||||
} else {
|
||||
now = time.Now()
|
||||
startTime = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).UnixMilli()
|
||||
toolArgs["startTime"] = startTime
|
||||
}
|
||||
if v := flagOrFallback(cmd, "end", "time-max", "max-time", "end-time", "endTime", "end_time", "end-date", "endDate"); v != "" {
|
||||
endTime, err = parseISOTimeToMillis("end", v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["endTime"] = endTime
|
||||
} else {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
endTime = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli()
|
||||
toolArgs["endTime"] = endTime
|
||||
}
|
||||
if err := validateTimeRange(startTime, endTime); err != nil {
|
||||
return err
|
||||
}
|
||||
if v := flagOrFallback(cmd, "calendar-id", "calendarId", "calendar"); v != "" {
|
||||
toolArgs["calendarId"] = v
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-cursor", "nextCursor", "page-token", "pageToken", "next-token"); v != "" {
|
||||
toolArgs["cursor"] = v
|
||||
}
|
||||
if lim, _ := cmd.Flags().GetInt("limit"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("max-results"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("maxResults"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("page-size"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("size"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
} else if lim, _ := cmd.Flags().GetInt("count"); lim > 0 {
|
||||
toolArgs["limit"] = lim
|
||||
}
|
||||
return callSortedCalendarEvents(cmd, "list_event_instances", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(eventInstancesCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "calendar",
|
||||
Name: "list_event_instances",
|
||||
CanonicalPath: "calendar.list_event_instances",
|
||||
CLIPath: "calendar event instances",
|
||||
PrimaryCLIPath: "calendar event instances",
|
||||
},
|
||||
Description: "查询循环日程的实例列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "calendar", RPCName: "list_event_instances"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询循环日程在时间范围内展开的实例",
|
||||
UseWhen: []string{"已知循环日程 eventId(SeriesMaster),需要列出某时间窗内的实例时"},
|
||||
AvoidWhen: []string{
|
||||
"普通非循环日程请用 dws calendar event get / list",
|
||||
"未知 eventId 时先 dws calendar event list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws calendar event instances --id <EVENT_ID>",
|
||||
"dws calendar event instances --id <EVENT_ID> --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-31T23:59:59+08:00\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "eventId", Required: boolPtr(true)},
|
||||
{Name: "start", Property: "startTime"},
|
||||
{Name: "end", Property: "endTime"},
|
||||
{Name: "calendar-id", Property: "calendarId"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
{Name: "limit", Property: "limit", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// InstancesEvent flags (aligned with event list aliases)
|
||||
eventInstancesCmd.Flags().String("id", "", "日程 ID (必填)")
|
||||
eventInstancesCmd.Flags().String("event", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("event")
|
||||
eventInstancesCmd.Flags().String("event-id", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("event-id")
|
||||
eventInstancesCmd.Flags().String("eventId", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("eventId")
|
||||
eventInstancesCmd.Flags().String("start", "", "开始时间 ISO-8601 (例如 2026-03-10T00:00:00+08:00)")
|
||||
eventInstancesCmd.Flags().String("time-min", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("time-min")
|
||||
eventInstancesCmd.Flags().String("min-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("min-time")
|
||||
eventInstancesCmd.Flags().String("start-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start-time")
|
||||
eventInstancesCmd.Flags().String("startTime", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("startTime")
|
||||
eventInstancesCmd.Flags().String("start_time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start_time")
|
||||
eventInstancesCmd.Flags().String("start-date", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("start-date")
|
||||
eventInstancesCmd.Flags().String("startDate", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("startDate")
|
||||
eventInstancesCmd.Flags().String("end", "", "结束时间 ISO-8601 (例如 2026-03-31T23:59:59+08:00)")
|
||||
eventInstancesCmd.Flags().String("time-max", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("time-max")
|
||||
eventInstancesCmd.Flags().String("max-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("max-time")
|
||||
eventInstancesCmd.Flags().String("end-time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end-time")
|
||||
eventInstancesCmd.Flags().String("endTime", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("endTime")
|
||||
eventInstancesCmd.Flags().String("end_time", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end_time")
|
||||
eventInstancesCmd.Flags().String("end-date", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("end-date")
|
||||
eventInstancesCmd.Flags().String("endDate", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("endDate")
|
||||
eventInstancesCmd.Flags().String("calendar-id", "", "日历 ID (可选,默认 primary 主日历;指定其他日历本时填写,可通过 book list 获取)")
|
||||
eventInstancesCmd.Flags().String("calendarId", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("calendarId")
|
||||
eventInstancesCmd.Flags().String("calendar", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("calendar")
|
||||
eventInstancesCmd.Flags().String("cursor", "", "分页游标 (首次查询无需传入,仅翻页时传入上一次返回的 nextCursor)")
|
||||
eventInstancesCmd.Flags().String("next-cursor", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("next-cursor")
|
||||
eventInstancesCmd.Flags().String("nextCursor", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("nextCursor")
|
||||
eventInstancesCmd.Flags().String("page-token", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("page-token")
|
||||
eventInstancesCmd.Flags().String("pageToken", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("pageToken")
|
||||
eventInstancesCmd.Flags().String("next-token", "", "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("next-token")
|
||||
eventInstancesCmd.Flags().Int("limit", 0, "每页返回条数 (默认 100,最大 100)")
|
||||
eventInstancesCmd.Flags().Int("max-results", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("max-results")
|
||||
eventInstancesCmd.Flags().Int("maxResults", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("maxResults")
|
||||
eventInstancesCmd.Flags().Int("page-size", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("page-size")
|
||||
eventInstancesCmd.Flags().Int("size", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("size")
|
||||
eventInstancesCmd.Flags().Int("count", 0, "")
|
||||
_ = eventInstancesCmd.Flags().MarkHidden("count")
|
||||
|
||||
eventCmd.AddCommand(eventListCmd, eventGetCmd, eventCreateCmd, eventUpdateCmd, eventDeleteCmd, eventSuggestCmd, eventRespondCmd, eventInstancesCmd)
|
||||
|
||||
// participant
|
||||
participantCmd.PersistentFlags().String("event", "", "日程 ID (必填)")
|
||||
|
||||
+174
-40
@@ -1086,6 +1086,63 @@ func uploadConversationLocalFile(ctx context.Context, targetArgs map[string]any,
|
||||
return callMCPToolReturnTextOnServer(ctx, "im", "commit_conversation_file_upload", commitArgs)
|
||||
}
|
||||
|
||||
func parseConversationFileDownloadURL(text string) (string, error) {
|
||||
var data map[string]any
|
||||
if err := unmarshalJSONUseNumber(text, &data); err != nil {
|
||||
return "", fmt.Errorf("failed to parse uploaded file response JSON: %w", err)
|
||||
}
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
downloadURL := firstStringField(data, "downloadUrl")
|
||||
if downloadURL == "" {
|
||||
return "", fmt.Errorf("uploaded file response missing downloadUrl")
|
||||
}
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
func uploadRobotMessageLocalFile(
|
||||
cmd *cobra.Command,
|
||||
filePath string,
|
||||
groupID string,
|
||||
userIDs []string,
|
||||
openDingTalkIDs []string,
|
||||
) (string, error) {
|
||||
uploadTarget := map[string]any{}
|
||||
if groupID != "" {
|
||||
uploadTarget["openConversationId"] = groupID
|
||||
} else {
|
||||
if len(userIDs)+len(openDingTalkIDs) != 1 {
|
||||
return "", fmt.Errorf("--file-path requires exactly one --users or --open-dingtalk-ids recipient")
|
||||
}
|
||||
if len(userIDs) == 1 {
|
||||
uploadTarget["userId"] = userIDs[0]
|
||||
} else {
|
||||
uploadTarget["openDingTalkId"] = openDingTalkIDs[0]
|
||||
}
|
||||
}
|
||||
|
||||
meta, err := buildConversationLocalFileMeta(filePath, "", "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "上传本地文件并由机器人发送")
|
||||
deps.Out.PrintKeyValue("文件", meta.LocalPath)
|
||||
deps.Out.PrintKeyValue("名称", meta.FileName)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", meta.FileSize))
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
|
||||
defer cancel()
|
||||
commitText, err := uploadConversationLocalFile(ctx, uploadTarget, meta, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parseConversationFileDownloadURL(commitText)
|
||||
}
|
||||
|
||||
func buildConversationFileContent(dentryID, spaceID int64, meta conversationLocalFileMeta) (string, error) {
|
||||
content := struct {
|
||||
DentryID int64 `json:"dentryId"`
|
||||
@@ -2143,14 +2200,18 @@ func newChatCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
|
||||
// send-by-bot: 群聊传 --group,单聊传 --users/--open-dingtalk-ids;--text 支持 Markdown
|
||||
// send-by-bot: 群聊传 --group,单聊传 --users/--open-dingtalk-ids。
|
||||
// Markdown 使用 --title/--text,图片使用 --msg-type image/--image-url,
|
||||
// 文件使用 --msg-type file/--file-path,CLI 上传后发送。
|
||||
chatMessageSendByBotCmd := &cobra.Command{
|
||||
Use: "send-by-bot",
|
||||
Short: "机器人发送消息(--group 群聊 / --users 单聊)",
|
||||
Long: `群聊:传 --group 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --group 只能选其一,不能同时指定。--text 支持 Markdown。
|
||||
Long: `群聊:传 --group 指定群;单聊:传 --users 或 --open-dingtalk-ids 指定用户列表,与 --group 只能选其一,不能同时指定。省略 --msg-type 时发送 Markdown;图片使用 --msg-type image --image-url;本地文件使用 --msg-type file --file-path,CLI 完成上传后发送。
|
||||
|
||||
⚠️ 重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。`,
|
||||
Example: ` dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --title "日报" --text "## 今日完成..."
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --msg-type image --image-url "https://example.com/image.png"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --msg-type file --file-path ./report.pdf
|
||||
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "请提交周报"
|
||||
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-user-ids userId1,userId2 --title "提醒" --text "@userId1 @userId2 请查收本周报告"
|
||||
@@ -2160,9 +2221,42 @@ func newChatCommand() *cobra.Command {
|
||||
# 查询 userId: dws contact user search --query "姓名"
|
||||
# robot-code: $DINGTALK_CHAT_ROBOT_CODE`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "robot-code", "title", "text"); err != nil {
|
||||
msgType := strings.TrimSpace(mustGetFlag(cmd, "msg-type"))
|
||||
title := mustGetFlag(cmd, "title")
|
||||
text := mustGetFlag(cmd, "text")
|
||||
imageURL := strings.TrimSpace(mustGetFlag(cmd, "image-url"))
|
||||
filePath := mustGetFlag(cmd, "file-path")
|
||||
|
||||
if err := validateRequiredFlags(cmd, "robot-code"); err != nil {
|
||||
return err
|
||||
}
|
||||
if msgType == "" {
|
||||
if imageURL != "" {
|
||||
return fmt.Errorf("--msg-type image is required when using --image-url")
|
||||
}
|
||||
if filePath != "" {
|
||||
return fmt.Errorf("--msg-type file is required when using --file-path")
|
||||
}
|
||||
msgType = "markdown"
|
||||
}
|
||||
switch msgType {
|
||||
case "markdown":
|
||||
if err := validateRequiredFlags(cmd, "title", "text"); err != nil {
|
||||
return err
|
||||
}
|
||||
case "image":
|
||||
if imageURL == "" {
|
||||
return fmt.Errorf("--image-url is required for --msg-type image")
|
||||
}
|
||||
case "file":
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("--file-path is required for --msg-type file")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported --msg-type %q, must be one of: markdown, image, file", msgType)
|
||||
}
|
||||
isMarkdownMessage := msgType == "markdown"
|
||||
|
||||
chatID := flagOrFallback(cmd, "group", "conversation-id", "id", "chat")
|
||||
usersStr, _ := cmd.Flags().GetString("users")
|
||||
openDingtalkIdsStr, _ := cmd.Flags().GetString("open-dingtalk-ids")
|
||||
@@ -2173,6 +2267,38 @@ func newChatCommand() *cobra.Command {
|
||||
if chatID == "" && !hasDirectTarget {
|
||||
return fmt.Errorf("--group or --users/--open-dingtalk-ids is required")
|
||||
}
|
||||
|
||||
userIDs := splitCommaList(usersStr)
|
||||
openDingTalkIDs := splitCommaList(openDingtalkIdsStr)
|
||||
fileURL := ""
|
||||
if msgType == "file" {
|
||||
var err error
|
||||
fileURL, err = uploadRobotMessageLocalFile(
|
||||
cmd, filePath, chatID, userIDs, openDingTalkIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
buildRobotMessageArgs := func(markdown string) map[string]any {
|
||||
toolArgs := map[string]any{
|
||||
"robotCode": mustGetFlag(cmd, "robot-code"),
|
||||
}
|
||||
switch msgType {
|
||||
case "image":
|
||||
toolArgs["photoURL"] = imageURL
|
||||
case "file":
|
||||
toolArgs["fileUrl"] = fileURL
|
||||
default:
|
||||
toolArgs["title"] = title
|
||||
toolArgs["markdown"] = markdown
|
||||
}
|
||||
return toolArgs
|
||||
}
|
||||
|
||||
if chatID != "" {
|
||||
var atUserIds []string
|
||||
if atUserIdsStr, _ := cmd.Flags().GetString("at-user-ids"); atUserIdsStr != "" {
|
||||
@@ -2190,17 +2316,23 @@ func newChatCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 机器人发消息要求 @ 占位符为裸 @id;模型若写成 <@id> 会导致 @ 不生效,主动剥离尖括号
|
||||
markdown := mustGetFlag(cmd, "text")
|
||||
markdown = normalizeAtPlaceholders(markdown, atUserIds, false)
|
||||
markdown = normalizeAtPlaceholders(markdown, atOpenDingtalkIds, false)
|
||||
markdown = strings.ReplaceAll(markdown, "<@all>", "@all")
|
||||
toolArgs := map[string]any{
|
||||
"robotCode": mustGetFlag(cmd, "robot-code"),
|
||||
"openConversationId": chatID,
|
||||
"title": mustGetFlag(cmd, "title"),
|
||||
"markdown": markdown,
|
||||
markdown := text
|
||||
if isMarkdownMessage {
|
||||
// 机器人发消息要求 @ 占位符为裸 @id;模型若写成 <@id> 会导致 @ 不生效,主动剥离尖括号
|
||||
markdown = normalizeAtPlaceholders(markdown, atUserIds, false)
|
||||
markdown = normalizeAtPlaceholders(markdown, atOpenDingtalkIds, false)
|
||||
markdown = strings.ReplaceAll(markdown, "<@all>", "@all")
|
||||
}
|
||||
toolArgs := buildRobotMessageArgs(markdown)
|
||||
switch msgType {
|
||||
case "image":
|
||||
toolArgs["msgKey"] = "sampleImageMsg"
|
||||
case "file":
|
||||
toolArgs["msgKey"] = "sampleDingtalkDriveFile"
|
||||
default:
|
||||
toolArgs["msgKey"] = "sampleMarkdownDX"
|
||||
}
|
||||
toolArgs["openConversationId"] = chatID
|
||||
if len(atUserIds) > 0 {
|
||||
toolArgs["atUserIds"] = atUserIds
|
||||
}
|
||||
@@ -2212,28 +2344,21 @@ func newChatCommand() *cobra.Command {
|
||||
}
|
||||
return callMCPToolOnServer("bot", "send_robot_group_message", toolArgs)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"robotCode": mustGetFlag(cmd, "robot-code"),
|
||||
"title": mustGetFlag(cmd, "title"),
|
||||
"markdown": mustGetFlag(cmd, "text"),
|
||||
|
||||
toolArgs := buildRobotMessageArgs(text)
|
||||
switch msgType {
|
||||
case "image":
|
||||
toolArgs["msgType"] = "sampleImageMsg"
|
||||
case "file":
|
||||
toolArgs["msgType"] = "sampleDingtalkDriveFile"
|
||||
default:
|
||||
toolArgs["msgType"] = "sampleMarkdownDX"
|
||||
}
|
||||
if usersStr != "" {
|
||||
var userIds []string
|
||||
for _, u := range strings.Split(usersStr, ",") {
|
||||
if s := strings.TrimSpace(u); s != "" {
|
||||
userIds = append(userIds, s)
|
||||
}
|
||||
}
|
||||
toolArgs["userIds"] = userIds
|
||||
if len(userIDs) > 0 {
|
||||
toolArgs["userIds"] = userIDs
|
||||
}
|
||||
if openDingtalkIdsStr != "" {
|
||||
var openDingtalkIds []string
|
||||
for _, id := range strings.Split(openDingtalkIdsStr, ",") {
|
||||
if s := strings.TrimSpace(id); s != "" {
|
||||
openDingtalkIds = append(openDingtalkIds, s)
|
||||
}
|
||||
}
|
||||
toolArgs["openDingtalkIds"] = openDingtalkIds
|
||||
if len(openDingTalkIDs) > 0 {
|
||||
toolArgs["openDingtalkIds"] = openDingTalkIDs
|
||||
}
|
||||
if isAtAll, _ := cmd.Flags().GetBool("at-all"); isAtAll {
|
||||
toolArgs["isAtAll"] = "true"
|
||||
@@ -2254,18 +2379,25 @@ func newChatCommand() *cobra.Command {
|
||||
CLIPath: "chat message send-by-bot",
|
||||
PrimaryCLIPath: "chat message send-by-bot",
|
||||
},
|
||||
Description: "以应用机器人身份发送群消息或批量单聊",
|
||||
Description: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "命令包含多个 RPC、条件分派或本地 HTTP/文件步骤,不能绑定为单一 interface_ref",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "以应用机器人身份发送群消息或批量单聊",
|
||||
UseWhen: []string{"已有 robotCode 且需要机器人身份投递消息时"},
|
||||
AgentSummary: "以应用机器人身份发送 Markdown、图片或文件群消息或批量单聊",
|
||||
UseWhen: []string{"已有 robotCode 且需要机器人身份投递 Markdown、图片或文件时"},
|
||||
AvoidWhen: []string{"个人身份发送或自定义 Webhook 告警不要使用"},
|
||||
Examples: []string{"dws chat message send-by-bot --robot-code <robotCode> --group <openConversationId> --title \"日报\" --text \"今日进展\""},
|
||||
},
|
||||
// Keep title/text required_when out of Schema: the runtime switch above
|
||||
// enforces Markdown inputs, while adding it breaks merge-base compatibility.
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "msg-type", RequiredWhen: "image-url or file-path is provided", Enum: []string{"markdown", "image", "file"}},
|
||||
{Name: "image-url", RequiredWhen: "msg-type is image"},
|
||||
{Name: "file-path", RequiredWhen: "msg-type is file"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3570,14 +3702,16 @@ func newChatCommand() *cobra.Command {
|
||||
_ = chatMessageSendByBotCmd.MarkFlagRequired("robot-code")
|
||||
chatMessageSendByBotCmd.Flags().String("group", "", "群聊 openConversationId(群聊时必填)")
|
||||
chatMessageSendByBotCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔,最多20个(单聊时必填)")
|
||||
chatMessageSendByBotCmd.Flags().String("title", "", "消息标题 (必填)")
|
||||
_ = chatMessageSendByBotCmd.MarkFlagRequired("title")
|
||||
chatMessageSendByBotCmd.Flags().String("text", "", "消息内容 Markdown (必填)")
|
||||
_ = chatMessageSendByBotCmd.MarkFlagRequired("text")
|
||||
chatMessageSendByBotCmd.Flags().String("msg-type", "", "消息类型: markdown/image/file(省略时为 markdown;图片使用 image --image-url;本地文件使用 file --file-path)")
|
||||
chatMessageSendByBotCmd.Flags().String("title", "", "Markdown 消息标题(发送 Markdown 时必填)")
|
||||
chatMessageSendByBotCmd.Flags().String("text", "", "Markdown 消息内容(发送 Markdown 时必填;稳定换行用空行,转义形式写 \\n\\n,不要只写 \\n)")
|
||||
chatMessageSendByBotCmd.Flags().String("image-url", "", "公网图片 URL(msgType=image 时必填)")
|
||||
chatMessageSendByBotCmd.Flags().String("file-path", "", "本地文件路径(msgType=file 时直接上传并按 file 消息发送)")
|
||||
chatMessageSendByBotCmd.Flags().String("at-user-ids", "", "@指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选),--text 中需包含 @userId 对应文本")
|
||||
chatMessageSendByBotCmd.Flags().String("open-dingtalk-ids", "", "用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)")
|
||||
chatMessageSendByBotCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)")
|
||||
chatMessageSendByBotCmd.Flags().Bool("at-all", false, "@所有人(可选),服务端接收字符串 true/false")
|
||||
cli.AnnotateRuntimeFlagFormat(chatMessageSendByBotCmd, "file-path", "file-path")
|
||||
|
||||
chatMessageRecallByBotCmd.Flags().String("robot-code", "", "机器人 Code (必填)")
|
||||
_ = chatMessageRecallByBotCmd.MarkFlagRequired("robot-code")
|
||||
|
||||
@@ -370,6 +370,131 @@ func TestCrossPlatformCoverageUploadConversationLocalFileCoverage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatBotRichMediaCoverage(t *testing.T) {
|
||||
testseam.Protect(t, &deps)
|
||||
testseam.Swap(t, &httpPutFile, func(context.Context, string, map[string]string, string, int64) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
filePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, response := range []string{`{`, `{"result":{}}`} {
|
||||
if _, err := parseConversationFileDownloadURL(response); err == nil {
|
||||
t.Fatalf("parseConversationFileDownloadURL(%q) returned nil", response)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "markdown requires title", args: []string{"--text", "message"}},
|
||||
{name: "markdown requires text", args: []string{"--title", "title"}},
|
||||
{name: "image requires URL", args: []string{"--msg-type", "image"}},
|
||||
{name: "file requires path", args: []string{"--msg-type", "file"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
args := []string{"message", "send-by-bot", "--robot-code", "robot", "--group", "group"}
|
||||
if err := runChatCoverageCommand(t, &scriptedToolCaller{}, append(args, tc.args...)...); err == nil {
|
||||
t.Fatal("missing rich-media input returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("group and direct targets are mutually exclusive", func(t *testing.T) {
|
||||
err := runChatCoverageCommand(t, &scriptedToolCaller{},
|
||||
"message", "send-by-bot", "--robot-code", "robot",
|
||||
"--group", "group", "--users", "user", "--title", "title", "--text", "message",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("mutually exclusive target error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct markdown defaults to DX message type", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
|
||||
"--title", "title", "--text", "message",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleMarkdownDX" {
|
||||
t.Fatalf("direct markdown call = %s %#v", caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct image", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--users", "user",
|
||||
"--msg-type", "image", "--image-url", "https://example.test/image.png", "--at-all",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleImageMsg" || caller.args["isAtAll"] != "true" {
|
||||
t.Fatalf("direct image call = %s %#v", caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
target []string
|
||||
}{
|
||||
{name: "user file", target: []string{"--users", "user"}},
|
||||
{name: "open DingTalk ID file", target: []string{"--open-dingtalk-ids", "D-user"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://upload.example/file","uploadKey":"key"}`},
|
||||
{text: `{"result":{"downloadUrl":"https://download.example/report.pdf"}}`},
|
||||
{text: `{"success":true}`},
|
||||
}}
|
||||
args := []string{"message", "send-by-bot", "--robot-code", "robot", "--msg-type", "file", "--file-path", filePath}
|
||||
if err := runChatCoverageCommand(t, caller, append(args, tc.target...)...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 3 || caller.tool != "batch_send_robot_msg_to_users" || caller.args["msgType"] != "sampleDingtalkDriveFile" || caller.args["fileUrl"] != "https://download.example/report.pdf" {
|
||||
t.Fatalf("direct file call = %d %s %#v", caller.calls, caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("file dry run", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{dry: true}
|
||||
if err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--group", "group",
|
||||
"--msg-type", "file", "--file-path", filePath,
|
||||
); err != nil || caller.calls != 0 {
|
||||
t.Fatalf("dry run error = %v, calls = %d", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file rejects multiple recipients", func(t *testing.T) {
|
||||
err := runChatCoverageCommand(t, &scriptedToolCaller{},
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--users", "one,two",
|
||||
"--msg-type", "file", "--file-path", filePath,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires exactly one") {
|
||||
t.Fatalf("multiple-recipient error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("file upload failure", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("upload failed")}}}
|
||||
err := runChatCoverageCommand(t, caller,
|
||||
"message", "send-by-bot", "--robot-code", "robot", "--group", "group",
|
||||
"--msg-type", "file", "--file-path", filePath,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "upload failed") {
|
||||
t.Fatalf("upload error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGuardGroupOwnerRemovalCoverage(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "chat"}
|
||||
|
||||
@@ -144,7 +144,6 @@ var (
|
||||
"clear": {},
|
||||
"refresh": {},
|
||||
"recover": {},
|
||||
"recovery": {},
|
||||
"login": {},
|
||||
"logout": {},
|
||||
"register": {},
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -80,6 +81,101 @@ func requireTypedConfirmationError(t *testing.T, err error) {
|
||||
}
|
||||
}
|
||||
|
||||
type guardedStepCaller struct {
|
||||
calls []guardedMutationCall
|
||||
steps []string
|
||||
index int
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *guardedStepCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
|
||||
text := `{}`
|
||||
if c.index < len(c.steps) {
|
||||
text = c.steps[c.index]
|
||||
}
|
||||
c.index++
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (*guardedStepCaller) Format() string { return "json" }
|
||||
func (c *guardedStepCaller) DryRun() bool { return c.dryRun }
|
||||
func (*guardedStepCaller) Fields() string { return "" }
|
||||
func (*guardedStepCaller) JQ() string { return "" }
|
||||
|
||||
func executeGuardedMailMutationCommand(t *testing.T, caller edition.ToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "mail"}, args...)
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
root := newMailCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if root.InOrStdin() == os.Stdin {
|
||||
root.SetIn(strings.NewReader(""))
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestMailMessageShareToChatRequiresConfirmationBeforeToolCall(t *testing.T) {
|
||||
baseArgs := []string{
|
||||
"message", "share-to-chat",
|
||||
"--email", "user@company.com",
|
||||
"--id", "msg-1",
|
||||
"--users", "uid1",
|
||||
}
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMailMutationCommand(t, caller, baseArgs...)
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %#v, want none before confirmation", caller.calls)
|
||||
}
|
||||
|
||||
stepCaller := &guardedStepCaller{steps: []string{
|
||||
`{"result":{"sign":"sig","riskMessage":"careful"}}`,
|
||||
`{"ok":true}`,
|
||||
}}
|
||||
err = executeGuardedMailMutationCommand(t, stepCaller, append(append([]string(nil), baseArgs...), "--yes")...)
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed share with sign returned error: %v", err)
|
||||
}
|
||||
if len(stepCaller.calls) != 2 {
|
||||
t.Fatalf("tool calls = %d, want 2 for sign retry", len(stepCaller.calls))
|
||||
}
|
||||
if stepCaller.calls[0].toolName != "share_message_to_chat" || stepCaller.calls[1].toolName != "share_message_to_chat" {
|
||||
t.Fatalf("tool calls = %#v, want share_message_to_chat twice", stepCaller.calls)
|
||||
}
|
||||
if sign, _ := stepCaller.calls[1].args["sign"].(string); sign != "sig" {
|
||||
t.Fatalf("second call sign = %q, want sig", sign)
|
||||
}
|
||||
|
||||
directCaller := &guardedStepCaller{steps: []string{`{"ok":true}`}}
|
||||
err = executeGuardedMailMutationCommand(t, directCaller, append(append([]string(nil), baseArgs...), "--yes")...)
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed direct success returned error: %v", err)
|
||||
}
|
||||
if len(directCaller.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want 1 for direct success", len(directCaller.calls))
|
||||
}
|
||||
if directCaller.calls[0].toolName != "share_message_to_chat" {
|
||||
t.Fatalf("tool call = %#v, want share_message_to_chat", directCaller.calls[0])
|
||||
}
|
||||
|
||||
dryRunCaller := &guardedMutationCaller{dryRun: true}
|
||||
err = executeGuardedMailMutationCommand(t, dryRunCaller, baseArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run without --yes returned error: %v", err)
|
||||
}
|
||||
if len(dryRunCaller.calls) != 0 {
|
||||
t.Fatalf("dry-run tool calls = %#v, want none", dryRunCaller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatDismissGroupRequiresConfirmationBeforeToolCall(t *testing.T) {
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
|
||||
@@ -16,6 +16,7 @@ package helpers
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -70,6 +71,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(root)
|
||||
|
||||
doc := &cobra.Command{
|
||||
Use: "doc",
|
||||
@@ -81,6 +83,7 @@ func (devHandler) Command(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(doc)
|
||||
doc.AddCommand(newDevDocSearchCommand())
|
||||
|
||||
root.AddCommand(
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -108,6 +109,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(root)
|
||||
|
||||
webapp := &cobra.Command{
|
||||
Use: "webapp",
|
||||
@@ -119,6 +121,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(webapp)
|
||||
webapp.AddCommand(
|
||||
newDevAppWebappGetCommand(runner),
|
||||
newDevAppWebappConfigCommand(runner),
|
||||
@@ -134,6 +137,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(permission)
|
||||
permission.AddCommand(
|
||||
newDevAppPermissionListCommand(runner),
|
||||
newDevAppPermissionAddCommand(runner),
|
||||
@@ -150,6 +154,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(credentials)
|
||||
credentials.AddCommand(newDevAppCredentialsGetCommand(runner))
|
||||
|
||||
member := &cobra.Command{
|
||||
@@ -162,6 +167,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(member)
|
||||
member.AddCommand(
|
||||
newDevAppMemberListCommand(runner),
|
||||
newDevAppMemberAddCommand(runner),
|
||||
@@ -178,6 +184,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(security)
|
||||
security.AddCommand(newDevAppSecurityConfigCommand(runner))
|
||||
|
||||
robot := &cobra.Command{
|
||||
@@ -190,6 +197,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(robot)
|
||||
robot.AddCommand(
|
||||
newDevAppRobotSubmitCommand(runner),
|
||||
newDevAppRobotResultCommand(runner),
|
||||
@@ -209,6 +217,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(version)
|
||||
version.AddCommand(
|
||||
newDevAppVersionCreateCommand(runner),
|
||||
newDevAppVersionListCommand(runner),
|
||||
@@ -228,6 +237,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmdutil.MarkGroup(event)
|
||||
event.AddCommand(
|
||||
newDevAppEventListCommand(runner),
|
||||
newDevAppEventSubscribeCommand(runner),
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// Copyright 2022 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
// This file is adapted from Go standard library's internal/diff package.
|
||||
// The original source can be found at /usr/local/go/src/internal/diff/diff.go.
|
||||
//
|
||||
// Modifications:
|
||||
// - Package changed from "diff" to "products"
|
||||
// - Diff() renamed to UnifiedDiff() and accepts a contextLines parameter
|
||||
// - const C replaced with the contextLines parameter
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A pair is a pair of values tracked for both the x and y side of a diff.
|
||||
// It is typically a pair of line indexes.
|
||||
type diffPair struct{ x, y int }
|
||||
|
||||
func nonNeg(v int) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// UnifiedDiff returns an anchored diff of the two texts old and new
|
||||
// in the "unified diff" format. If old and new are identical,
|
||||
// UnifiedDiff returns a nil slice (no output).
|
||||
//
|
||||
// Unix diff implementations typically look for a diff with
|
||||
// the smallest number of lines inserted and removed,
|
||||
// which can in the worst case take time quadratic in the
|
||||
// number of lines in the texts. As a result, many implementations
|
||||
// either can be made to run for a long time or cut off the search
|
||||
// after a predetermined amount of work.
|
||||
//
|
||||
// In contrast, this implementation looks for a diff with the
|
||||
// smallest number of "unique" lines inserted and removed,
|
||||
// where unique means a line that appears just once in both old and new.
|
||||
// We call this an "anchored diff" because the unique lines anchor
|
||||
// the chosen matching regions. An anchored diff is usually clearer
|
||||
// than a standard diff, because the algorithm does not try to
|
||||
// reuse unrelated blank lines or closing braces.
|
||||
// The algorithm also guarantees to run in O(n log n) time
|
||||
// instead of the standard O(n²) time.
|
||||
//
|
||||
// Some systems call this approach a "patience diff," named for
|
||||
// the "patience sorting" algorithm, itself named for a solitaire card game.
|
||||
// We avoid that name for two reasons. First, the name has been used
|
||||
// for a few different variants of the algorithm, so it is imprecise.
|
||||
// Second, the name is frequently interpreted as meaning that you have
|
||||
// to wait longer (to be patient) for the diff, meaning that it is a slower algorithm,
|
||||
// when in fact the algorithm is faster than the standard one.
|
||||
func UnifiedDiff(oldName string, old []byte, newName string, new []byte, contextLines int) []byte {
|
||||
if bytes.Equal(old, new) {
|
||||
return nil
|
||||
}
|
||||
x := diffLines(old)
|
||||
y := diffLines(new)
|
||||
|
||||
// Print diff header.
|
||||
var out bytes.Buffer
|
||||
fmt.Fprintf(&out, "diff %s %s\n", oldName, newName)
|
||||
fmt.Fprintf(&out, "--- %s\n", oldName)
|
||||
fmt.Fprintf(&out, "+++ %s\n", newName)
|
||||
|
||||
// Loop over matches to consider,
|
||||
// expanding each match to include surrounding lines,
|
||||
// and then printing diff chunks.
|
||||
// To avoid setup/teardown cases outside the loop,
|
||||
// tgs returns a leading {0,0} and trailing {len(x), len(y)} pair
|
||||
// in the sequence of matches.
|
||||
var (
|
||||
done diffPair // printed up to x[:done.x] and y[:done.y]
|
||||
chunk diffPair // start lines of current chunk
|
||||
count diffPair // number of lines from each side in current chunk
|
||||
ctext []string // lines for current chunk
|
||||
)
|
||||
for _, m := range diffTgs(x, y) {
|
||||
if m.x < done.x {
|
||||
// Already handled scanning forward from earlier match.
|
||||
continue
|
||||
}
|
||||
|
||||
// Expand matching lines as far as possible,
|
||||
// establishing that x[start.x:end.x] == y[start.y:end.y].
|
||||
// Note that on the first (or last) iteration we may (or definitely do)
|
||||
// have an empty match: start.x==end.x and start.y==end.y.
|
||||
start := m
|
||||
for start.x > done.x && start.y > done.y && x[start.x-1] == y[start.y-1] {
|
||||
start.x--
|
||||
start.y--
|
||||
}
|
||||
end := m
|
||||
for end.x < len(x) && end.y < len(y) && x[end.x] == y[end.y] {
|
||||
end.x++
|
||||
end.y++
|
||||
}
|
||||
|
||||
// Emit the mismatched lines before start into this chunk.
|
||||
// (No effect on first sentinel iteration, when start = {0,0}.)
|
||||
for _, s := range x[done.x:start.x] {
|
||||
ctext = append(ctext, "-"+s)
|
||||
count.x++
|
||||
}
|
||||
for _, s := range y[done.y:start.y] {
|
||||
ctext = append(ctext, "+"+s)
|
||||
count.y++
|
||||
}
|
||||
|
||||
// If we're not at EOF and have too few common lines,
|
||||
// the chunk includes all the common lines and continues.
|
||||
C := contextLines
|
||||
if C < 0 {
|
||||
// 防御性兼容:负值会破坏下方区间判定,按无上下文处理;0 是合法值,直接生效
|
||||
C = 0
|
||||
}
|
||||
if (end.x < len(x) || end.y < len(y)) &&
|
||||
(end.x-start.x < C || (len(ctext) > 0 && end.x-start.x < 2*C)) {
|
||||
for _, s := range x[start.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
continue
|
||||
}
|
||||
|
||||
// End chunk with common lines for context.
|
||||
if len(ctext) > 0 {
|
||||
n := end.x - start.x
|
||||
if n > C {
|
||||
n = C
|
||||
}
|
||||
for _, s := range x[start.x : start.x+n] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = diffPair{start.x + n, start.y + n}
|
||||
|
||||
// Format and emit chunk.
|
||||
// Convert line numbers to 1-indexed.
|
||||
// Special case: empty file shows up as 0,0 not 1,0.
|
||||
if count.x > 0 {
|
||||
chunk.x++
|
||||
}
|
||||
if count.y > 0 {
|
||||
chunk.y++
|
||||
}
|
||||
fmt.Fprintf(&out, "@@ -%d,%d +%d,%d @@\n", chunk.x, count.x, chunk.y, count.y)
|
||||
for _, s := range ctext {
|
||||
out.WriteString(s)
|
||||
}
|
||||
count.x = 0
|
||||
count.y = 0
|
||||
ctext = ctext[:0]
|
||||
}
|
||||
|
||||
// If we reached EOF, we're done.
|
||||
if end.x >= len(x) && end.y >= len(y) {
|
||||
break
|
||||
}
|
||||
|
||||
// Otherwise start a new chunk.
|
||||
// C is clamped to >= 0 above; nonNeg saturates end-C when the next
|
||||
// hunk would start before line 0 (defensive; exercised via nonNeg tests).
|
||||
chunk = diffPair{nonNeg(end.x - C), nonNeg(end.y - C)}
|
||||
for _, s := range x[chunk.x:end.x] {
|
||||
ctext = append(ctext, " "+s)
|
||||
count.x++
|
||||
count.y++
|
||||
}
|
||||
done = end
|
||||
}
|
||||
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// diffLines returns the lines in the file x, including newlines.
|
||||
// If the file does not end in a newline, one is supplied
|
||||
// along with a warning about the missing newline.
|
||||
func diffLines(x []byte) []string {
|
||||
l := strings.SplitAfter(string(x), "\n")
|
||||
if l[len(l)-1] == "" {
|
||||
l = l[:len(l)-1]
|
||||
} else {
|
||||
// Treat last line as having a message about the missing newline attached,
|
||||
// using the same text as BSD/GNU diff (including the leading backslash).
|
||||
l[len(l)-1] += "\n\\ No newline at end of file\n"
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// diffTgs returns the pairs of indexes of the longest common subsequence
|
||||
// of unique lines in x and y, where a unique line is one that appears
|
||||
// once in x and once in y.
|
||||
//
|
||||
// The longest common subsequence algorithm is as described in
|
||||
// Thomas G. Szymanski, "A Special Case of the Maximal Common
|
||||
// Subsequence Problem," Princeton TR #170 (January 1975),
|
||||
// available at https://research.swtch.com/tgs170.pdf.
|
||||
func diffTgs(x, y []string) []diffPair {
|
||||
// Count the number of times each string appears in a and b.
|
||||
// We only care about 0, 1, many, counted as 0, -1, -2
|
||||
// for the x side and 0, -4, -8 for the y side.
|
||||
// Using negative numbers now lets us distinguish positive line numbers later.
|
||||
m := make(map[string]int)
|
||||
for _, s := range x {
|
||||
if c := m[s]; c > -2 {
|
||||
m[s] = c - 1
|
||||
}
|
||||
}
|
||||
for _, s := range y {
|
||||
if c := m[s]; c > -8 {
|
||||
m[s] = c - 4
|
||||
}
|
||||
}
|
||||
|
||||
// Now unique strings can be identified by m[s] = -1+-4.
|
||||
//
|
||||
// Gather the indexes of those strings in x and y, building:
|
||||
// xi[i] = increasing indexes of unique strings in x.
|
||||
// yi[i] = increasing indexes of unique strings in y.
|
||||
// inv[i] = index j such that x[xi[i]] = y[yi[j]].
|
||||
var xi, yi, inv []int
|
||||
for i, s := range y {
|
||||
if m[s] == -1+-4 {
|
||||
m[s] = len(yi)
|
||||
yi = append(yi, i)
|
||||
}
|
||||
}
|
||||
for i, s := range x {
|
||||
if j, ok := m[s]; ok && j >= 0 {
|
||||
xi = append(xi, i)
|
||||
inv = append(inv, j)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Algorithm A from Szymanski's paper.
|
||||
// In those terms, A = J = inv and B = [0, n).
|
||||
// We add sentinel pairs {0,0}, and {len(x),len(y)}
|
||||
// to the returned sequence, to help the processing loop.
|
||||
J := inv
|
||||
n := len(xi)
|
||||
T := make([]int, n)
|
||||
L := make([]int, n)
|
||||
for i := range T {
|
||||
T[i] = n + 1
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
k := sort.Search(n, func(k int) bool {
|
||||
return T[k] >= J[i]
|
||||
})
|
||||
T[k] = J[i]
|
||||
L[i] = k + 1
|
||||
}
|
||||
k := 0
|
||||
for _, v := range L {
|
||||
if k < v {
|
||||
k = v
|
||||
}
|
||||
}
|
||||
seq := make([]diffPair, 2+k)
|
||||
seq[1+k] = diffPair{len(x), len(y)} // sentinel at end
|
||||
lastj := n
|
||||
for i := n - 1; i >= 0; i-- {
|
||||
if L[i] == k && J[i] < lastj {
|
||||
seq[k] = diffPair{xi[i], yi[J[i]]}
|
||||
k--
|
||||
}
|
||||
}
|
||||
seq[0] = diffPair{0, 0} // sentinel at start
|
||||
return seq
|
||||
}
|
||||
+11
-4
@@ -3657,11 +3657,13 @@ CLI 内部自动完成全部流程:
|
||||
PROCESSING 处理中
|
||||
SUCCESS 导出成功,返回 downloadUrl
|
||||
FAILED 导出失败`,
|
||||
Example: ` dws doc export get --job-id <JOB_ID>`,
|
||||
Example: ` dws doc export get --job-id <JOB_ID>
|
||||
dws doc export get --task-id <TASK_ID>`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
jobID := mustGetFlag(cmd, "job-id")
|
||||
if jobID == "" {
|
||||
return fmt.Errorf("flag --job-id is required")
|
||||
// Keep --job-id as the visible primary; --task-id is an add-only synonym.
|
||||
jobID, err := mustFlagOrFallback(cmd, "job-id", "task-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
@@ -3728,9 +3730,14 @@ CLI 内部自动完成全部流程:
|
||||
AvoidWhen: []string{"常规导出请直接 dws doc export(一体化提交+轮询+下载),不要先查 job"},
|
||||
Examples: []string{"dws doc export get --job-id <JOB_ID> --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "job-id", Property: "jobId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
exportGetCmd.Flags().String("job-id", "", "导出任务 ID (必填)")
|
||||
exportGetCmd.Flags().String("task-id", "", "--job-id 的别名")
|
||||
_ = exportGetCmd.Flags().MarkHidden("task-id")
|
||||
|
||||
// --node 的隐藏别名(与 doc 下其他命令保持一致)
|
||||
exportCmd.Flags().String("url", "", "--node 的别名")
|
||||
|
||||
@@ -316,12 +316,24 @@ func newDriveCommand() *cobra.Command {
|
||||
Example: ` dws drive list --limit 20
|
||||
dws drive list --folder <dentryUuid> --order-by name --order asc
|
||||
dws drive list --workspace <workspaceId>
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>`,
|
||||
dws drive list --workspace <workspaceId> --folder <folderId>
|
||||
dws drive list --latest 5
|
||||
dws drive list --folder <dentryUuid> --latest 3 --pattern "*.docx"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
pattern, _ := cmd.Flags().GetString("pattern")
|
||||
|
||||
depth, _ := cmd.Flags().GetInt("depth")
|
||||
|
||||
latest, _ := cmd.Flags().GetInt("latest")
|
||||
if cmd.Flags().Changed("latest") {
|
||||
if err := validateDriveListLatest(cmd, latest); err != nil {
|
||||
return err
|
||||
}
|
||||
if cmd.Flags().Changed("versions") {
|
||||
return &CLIError{Code: CodeInvalidParam, Message: "--latest 不能与 --versions 同时使用"}
|
||||
}
|
||||
}
|
||||
|
||||
// --versions 模式:列出文件历史版本(仅普通文件)
|
||||
// 先于 --depth 校验执行:versions 模式合法使用 --limit,
|
||||
// 不应被「--limit 与 --depth 不兼容」的误导性报错拦截。
|
||||
@@ -363,7 +375,7 @@ func newDriveCommand() *cobra.Command {
|
||||
if workspaceID != "" {
|
||||
// depth>1 时 --pattern 放开(先递归后过滤);--order-by/--space-id/--thumbnail
|
||||
// 知识库无对应参数,静默忽略。
|
||||
if depth > 1 {
|
||||
if depth > 1 || latest > 0 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{"workspaceId": workspaceID}
|
||||
rootFolder := docFolderFlag(cmd, "node", "file-id")
|
||||
@@ -372,7 +384,7 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
return runDriveListDepth(cmd, newDocDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
}
|
||||
if pattern != "" {
|
||||
return &CLIError{
|
||||
@@ -417,10 +429,27 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet)
|
||||
return runDriveListDepth(cmd, newDrivePanDepthRoute(), baseArgs, rootFolder, depth, pattern, quiet, latest)
|
||||
}
|
||||
|
||||
// 默认路由:钉盘文件列表
|
||||
if latest > 0 {
|
||||
quiet, _ := cmd.Flags().GetBool("quiet")
|
||||
baseArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("space-id"); v != "" {
|
||||
baseArgs["spaceId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("thumbnail"); v {
|
||||
baseArgs["withThumbnail"] = true
|
||||
}
|
||||
rootFolder := flagOrFallback(cmd, "folder", "parent-id")
|
||||
if rootFolder != "" {
|
||||
if err := validateDriveParentID(rootFolder); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return runDriveListLatest(cmd, baseArgs, rootFolder, latest, pattern, quiet)
|
||||
}
|
||||
maxResults, _ := cmd.Flags().GetInt("limit")
|
||||
if !cmd.Flags().Changed("limit") {
|
||||
if v, _ := cmd.Flags().GetInt("max"); v > 0 {
|
||||
@@ -491,7 +520,7 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
Examples: []string{
|
||||
"dws drive list --limit 20 --format json",
|
||||
"dws drive list --folder <dentryUuid> --limit 20 --format json",
|
||||
"dws drive list --latest 5 --format json",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -701,6 +730,8 @@ func newDriveCommand() *cobra.Command {
|
||||
{Name: "part-size", Description: "分片下载的分片大小(如 8MB/16MB/1GB)"},
|
||||
{Name: "parallel", Description: "分片下载并发数(1-8)"},
|
||||
{Name: "no-resume", Description: "关闭断点续传"},
|
||||
// Wukong compat alias: routes to download-version; not a download_file property.
|
||||
{Name: "version", Description: "下载指定历史版本号(兼容别名,等价 download-version)"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1052,7 +1083,8 @@ func newDriveCommand() *cobra.Command {
|
||||
driveListCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (--versions 模式下必填)")
|
||||
driveListCmd.Flags().String("pattern", "", "按名称通配过滤结果,如 \"*日报*\" (客户端过滤) (可选)")
|
||||
driveListCmd.Flags().Int("depth", 1, "递归列出子目录层级,默认 1(仅当前层),最大 5;与 --cursor/--limit 互斥;与 --workspace 组合时走知识库递归 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (仅 --depth>1 时有效) (可选)")
|
||||
driveListCmd.Flags().Int("latest", 0, "按修改时间取最新 N 个文件(1~50);与 --pattern 组合时表示名称匹配的文件中最新 N 个;可与 --workspace/--depth 组合;与 --order-by/--order/--limit/--cursor 互斥 (可选)")
|
||||
driveListCmd.Flags().Bool("quiet", false, "关闭递归进度输出(stderr),不影响 stdout JSON (--depth>1 或 --latest 多页扫描时有效) (可选)")
|
||||
|
||||
driveInfoCmd.Flags().String("node", "", "节点 ID (dentryUuid) (必填)")
|
||||
driveInfoCmd.Flags().String("space-id", "", "节点所属空间 ID (可选)")
|
||||
@@ -1060,6 +1092,7 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadCmd.Flags().String("node", "", "文件 ID (dentryUuid) (必填)")
|
||||
driveDownloadCmd.Flags().String("space-id", "", "文件所属空间 ID (可选)")
|
||||
driveDownloadCmd.Flags().String("output", "", "本地保存路径 (文件路径或目录,必填)")
|
||||
driveDownloadCmd.Flags().Int("version", 0, "下载指定历史版本号(兼容别名,等价 download-version)")
|
||||
driveDownloadCmd.Flags().String("part-size", "16MB", "分片下载的分片大小,如 8MB/16MB/1GB,范围 1MB-1GB (可选)")
|
||||
driveDownloadCmd.Flags().Int("parallel", 4, "分片下载并发数,范围 1-8 (可选)")
|
||||
driveDownloadCmd.Flags().Bool("no-resume", false, "关闭断点续传 (可选)")
|
||||
@@ -1074,6 +1107,14 @@ func newDriveCommand() *cobra.Command {
|
||||
driveDownloadVersionCmd.Flags().String(alias, "", "")
|
||||
_ = driveDownloadVersionCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
// Wukong compat: `drive download --version N` routes to download-version.
|
||||
origDriveDownloadRunE := driveDownloadCmd.RunE
|
||||
driveDownloadCmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if cmd.Flags().Changed("version") {
|
||||
return driveDownloadVersionCmd.RunE(cmd, args)
|
||||
}
|
||||
return origDriveDownloadRunE(cmd, args)
|
||||
}
|
||||
|
||||
driveMkdirCmd.Flags().String("name", "", "文件夹名称,最长 50 字符 (必填)")
|
||||
driveMkdirCmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」 (可选)")
|
||||
@@ -2019,6 +2060,8 @@ func newDriveCommand() *cobra.Command {
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
@@ -2070,6 +2113,8 @@ func newDriveCommand() *cobra.Command {
|
||||
})
|
||||
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
drivePermListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = drivePermListCmd.Flags().MarkHidden("max-results")
|
||||
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
@@ -2733,7 +2778,7 @@ func newDriveCommand() *cobra.Command {
|
||||
|
||||
// ── cross-product hidden aliases ──
|
||||
for _, cmd := range []*cobra.Command{
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd,
|
||||
driveListCmd, driveListSpacesCmd, driveInfoCmd, driveDownloadCmd, driveDownloadVersionCmd,
|
||||
driveMkdirCmd, driveUploadInfoCmd, driveCommitCmd, driveUploadCmd, driveDeleteCmd,
|
||||
driveSearchCmd, driveCopyCmd, driveMoveCmd, driveRenameCmd, driveStatsCmd, driveShortcutCmd,
|
||||
driveFolderCreateCmd,
|
||||
@@ -3035,6 +3080,11 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
driveCoverCmd.Flags().String("node", "", "节点 ID (dentryUuid) 或文档 URL (必填)")
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
driveCoverCmd.Flags().String(alias, "", "--node 的别名")
|
||||
_ = driveCoverCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
RegisterCrossProductAliases(driveCoverCmd)
|
||||
|
||||
// ── drive revert (回滚文件到指定历史版本) ──
|
||||
driveRevertCmd := &cobra.Command{
|
||||
@@ -3087,6 +3137,21 @@ func newDriveCommand() *cobra.Command {
|
||||
})
|
||||
driveRevertCmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
driveRevertCmd.Flags().Int("version", 0, "要回滚到的历史版本号 (必填,正整数)")
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
driveRevertCmd.Flags().String(alias, "", "--node 的别名")
|
||||
_ = driveRevertCmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
RegisterCrossProductAliases(driveRevertCmd)
|
||||
|
||||
for _, child := range driveStarCmd.Commands() {
|
||||
for _, alias := range []string{"url", "id"} {
|
||||
if child.Flags().Lookup(alias) == nil {
|
||||
child.Flags().String(alias, "", "--node 的别名")
|
||||
_ = child.Flags().MarkHidden(alias)
|
||||
}
|
||||
}
|
||||
RegisterCrossProductAliases(child)
|
||||
}
|
||||
|
||||
driveCmd.AddCommand(
|
||||
driveListCmd,
|
||||
|
||||
@@ -178,7 +178,7 @@ func newDocDepthRoute() driveDepthRoute {
|
||||
}
|
||||
|
||||
// SIGINT 检查两点(出队后发首页前 + 翻页循环发每页前),入队是纯内存操作不检查。
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool) error {
|
||||
func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[string]any, rootFolderID string, maxDepth int, pattern string, quiet bool, latest int) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return printDriveDepthDryRun(route, baseArgs, maxDepth)
|
||||
}
|
||||
@@ -209,7 +209,7 @@ func runDriveListDepth(cmd *cobra.Command, route driveDepthRoute, baseArgs map[s
|
||||
bfs:
|
||||
for len(queue) > 0 {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
folder := queue[0]
|
||||
queue = queue[1:]
|
||||
@@ -220,7 +220,7 @@ bfs:
|
||||
pages := 0
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
pages++
|
||||
if pages > maxPagesPerFolder {
|
||||
@@ -233,7 +233,7 @@ bfs:
|
||||
args := route.buildArgs(baseArgs, folder.id, pageToken)
|
||||
text, err := route.fetchPage(ctx, args)
|
||||
if ctx.Err() != nil {
|
||||
return emitDriveDepthCancelled(collected, errs, pattern)
|
||||
return emitDriveDepthCancelled(collected, errs, pattern, latest, maxDepth)
|
||||
}
|
||||
if err != nil {
|
||||
folderErr = err
|
||||
@@ -249,6 +249,12 @@ bfs:
|
||||
item["depth"] = folder.depth + 1
|
||||
item["parentId"] = folder.id // 根级为空串
|
||||
item["rel_path"] = rel // 不保证唯一,组树以 parentId 为准
|
||||
// 时间戳归一:钉盘 modifyTime / 知识库 updateTime 统一为 sortTime(毫秒 int64)
|
||||
if ms, ok := driveItemModifiedMillis(item); ok {
|
||||
item["sortTime"] = ms
|
||||
} else {
|
||||
item["sortTime"] = int64(0)
|
||||
}
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= driveDepthMaxItems {
|
||||
// 未访问目录不记 errors[](没失败只是没扫),避免 errors 数组被淹没
|
||||
@@ -291,7 +297,7 @@ bfs:
|
||||
if driveDepthUnrecoverable(folderErr) {
|
||||
// partial 照吐 stdout,错误详情走 stderr,非零退出
|
||||
errs = append(errs, newDriveDepthError(folder, folderErr))
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern); emitErr != nil {
|
||||
if emitErr := emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
return folderErr
|
||||
@@ -324,18 +330,26 @@ bfs:
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern)
|
||||
if truncated && latest > 0 {
|
||||
return &CLIError{
|
||||
Code: CodeContentTruncated,
|
||||
Message: fmt.Sprintf("LATEST_SCAN_TRUNCATED: 扫描在全局上限 %d 条处截断,未扫描区域可能含更新文件,拒绝输出不完整的 Top-%d", driveDepthMaxItems, latest),
|
||||
Suggestion: fmt.Sprintf("缩小扫描范围后重试:--folder 指定子目录,或降低 --depth 层数,如 dws drive list --folder <子目录ID> --latest %d", latest),
|
||||
}
|
||||
}
|
||||
|
||||
return emitDriveDepthResult(collected, errs, truncated, pattern, latest, maxDepth)
|
||||
}
|
||||
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern); err != nil {
|
||||
func emitDriveDepthCancelled(items []map[string]any, errs []driveDepthError, pattern string, latest, reqDepth int) error {
|
||||
if err := emitDriveDepthResult(items, errs, true, pattern, latest, reqDepth); err != nil {
|
||||
return err
|
||||
}
|
||||
return &driveDepthCancelledError{}
|
||||
}
|
||||
|
||||
// depth>1 不输出 nextToken。
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string) error {
|
||||
func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, truncated bool, pattern string, latest, reqDepth int) error {
|
||||
if pattern != "" {
|
||||
// 先递归后过滤,过滤仅作用于输出项,不阻止文件夹下钻
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
@@ -350,21 +364,28 @@ func emitDriveDepthResult(items []map[string]any, errs []driveDepthError, trunca
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ri, _ := items[i]["rel_path"].(string)
|
||||
rj, _ := items[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||||
})
|
||||
if latest > 0 {
|
||||
items = applyDriveListLatest(items, latest)
|
||||
} else {
|
||||
// 排列为 rel_path 树序:BFS 只决定截断时哪些条目入选,树序决定呈现顺序。
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
ri, _ := items[i]["rel_path"].(string)
|
||||
rj, _ := items[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(items[i]) < driveDepthItemID(items[j])
|
||||
})
|
||||
}
|
||||
maxDepth := 0
|
||||
for _, item := range items {
|
||||
if d, ok := item["depth"].(int); ok && d > maxDepth {
|
||||
maxDepth = d
|
||||
}
|
||||
}
|
||||
if latest > 0 && reqDepth == 1 {
|
||||
stripDriveDepthDecorations(items)
|
||||
}
|
||||
if items == nil {
|
||||
items = []map[string]any{}
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "a-file.xlsx", "rel_path": "a", "depth": 2, "fileId": "f1"},
|
||||
{"name": "skip-me.csv", "rel_path": "c", "depth": 1, "fileId": "f3"},
|
||||
}
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx"); err != nil {
|
||||
if err := emitDriveDepthResult(items, nil, false, "*.xlsx", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := decodeDepthResult(t, out)
|
||||
@@ -325,7 +325,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err != nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result = decodeDepthResult(t, out)
|
||||
@@ -338,7 +338,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
{"name": "dup", "rel_path": "p/dup", "fileId": "a1"},
|
||||
}
|
||||
out.Reset()
|
||||
if err := emitDriveDepthResult(samePath, nil, false, ""); err != nil {
|
||||
if err := emitDriveDepthResult(samePath, nil, false, "", 0, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = decodeDepthResult(t, out)["items"].([]any)
|
||||
@@ -347,7 +347,7 @@ func TestCrossPlatformCoverageEmitDriveDepthResult(t *testing.T) {
|
||||
}
|
||||
|
||||
deps.Out.w = failingWriter{}
|
||||
if err := emitDriveDepthResult(nil, nil, false, ""); err == nil {
|
||||
if err := emitDriveDepthResult(nil, nil, false, "", 0, 0); err == nil {
|
||||
t.Fatal("failing writer returned nil")
|
||||
}
|
||||
}
|
||||
@@ -382,7 +382,7 @@ func runDepthBFS(t *testing.T, caller *scriptedToolCaller, route driveDepthRoute
|
||||
t.Helper()
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, root, maxDepth, pattern, true, 0)
|
||||
return decodeDepthResult(t, out), err
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ func TestCrossPlatformCoverageRunDriveListDepthDryRun(t *testing.T) {
|
||||
caller := &scriptedToolCaller{format: "json", dry: true}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 2, "", true, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
@@ -413,7 +413,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPanBFS(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", false, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 2 {
|
||||
@@ -548,7 +548,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRateLimitResumesFromFailedPage(t
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true); err != nil {
|
||||
if err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 3 {
|
||||
@@ -593,7 +593,7 @@ func TestCrossPlatformCoverageRunDriveListDepthRootFailure(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil {
|
||||
t.Fatal("root failure returned nil")
|
||||
}
|
||||
@@ -610,7 +610,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverable(t *testing.T) {
|
||||
}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil {
|
||||
t.Fatal("unrecoverable returned nil")
|
||||
}
|
||||
@@ -635,7 +635,7 @@ func TestCrossPlatformCoverageRunDriveListDepthUnrecoverableEmitFailure(t *testi
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -648,7 +648,7 @@ func TestCrossPlatformCoverageRunDriveListDepthPaginationLoop(t *testing.T) {
|
||||
}}
|
||||
installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "cursor loop suspected") {
|
||||
t.Fatalf("err = %v, want pagination anomaly", err)
|
||||
}
|
||||
@@ -690,7 +690,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelled(t *testing.T) {
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -710,7 +710,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledEmitFailure(t *testing.T
|
||||
cancel()
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -724,7 +724,7 @@ func TestCrossPlatformCoverageRunDriveListDepthFinalEmitFailure(t *testing.T) {
|
||||
installDepthCaller(t, caller)
|
||||
deps.Out.w = failingWriter{}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err = %v, want emit failure", err)
|
||||
}
|
||||
@@ -772,7 +772,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledInsidePagination(t *test
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, route, map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
@@ -801,7 +801,7 @@ func TestCrossPlatformCoverageRunDriveListDepthCancelledAfterFetch(t *testing.T)
|
||||
deps.Out.errW = io.Discard
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(ctx)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true)
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 0)
|
||||
var cancelErr *driveDepthCancelledError
|
||||
if !errors.As(err, &cancelErr) {
|
||||
t.Fatalf("err = %T %v, want driveDepthCancelledError", err, err)
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
// --latest 上限与 --limit 服务端每页硬上限 50 对齐。
|
||||
driveLatestMax = 50
|
||||
// 钉盘单层 latest 扫描上限(50×20 页)。
|
||||
driveLatestScanMax = 1000
|
||||
)
|
||||
|
||||
// validateDriveListLatest --latest 边界与互斥校验。
|
||||
func validateDriveListLatest(cmd *cobra.Command, latest int) error {
|
||||
if latest < 1 || latest > driveLatestMax {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--latest 必须为 1~%d 的整数,当前: %d", driveLatestMax, latest),
|
||||
}
|
||||
}
|
||||
for _, f := range []string{"order-by", "order"} {
|
||||
if cmd.Flags().Changed(f) {
|
||||
return driveLatestExclusiveError(f, latest)
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("limit") || cmd.Flags().Changed("max") {
|
||||
return driveLatestExclusiveError("limit", latest)
|
||||
}
|
||||
if v := flagOrFallback(cmd, "cursor", "next-token"); v != "" {
|
||||
return driveLatestExclusiveError("cursor", latest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func driveLatestExclusiveError(flag string, latest int) error {
|
||||
return &CLIError{
|
||||
Code: CodeInvalidParam,
|
||||
Message: fmt.Sprintf("--latest 不能与 --%s 同时使用:Top-N 排序语义由 latest 独占;如需自定义排序请改用 --order-by modifyTime --order desc --limit %d", flag, latest),
|
||||
}
|
||||
}
|
||||
|
||||
func applyDriveListLatest(items []map[string]any, latest int) []map[string]any {
|
||||
files := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if isDriveDepthFolder(item) || isDocDepthFolder(item) {
|
||||
continue
|
||||
}
|
||||
files = append(files, item)
|
||||
}
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
ti, _ := files[i]["sortTime"].(int64)
|
||||
tj, _ := files[j]["sortTime"].(int64)
|
||||
if ti != tj {
|
||||
return ti > tj
|
||||
}
|
||||
ri, _ := files[i]["rel_path"].(string)
|
||||
rj, _ := files[j]["rel_path"].(string)
|
||||
if ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return driveDepthItemID(files[i]) < driveDepthItemID(files[j])
|
||||
})
|
||||
if len(files) > latest {
|
||||
files = files[:latest]
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func stripDriveDepthDecorations(items []map[string]any) {
|
||||
for _, item := range items {
|
||||
delete(item, "depth")
|
||||
delete(item, "parentId")
|
||||
delete(item, "rel_path")
|
||||
delete(item, "sortTime")
|
||||
}
|
||||
}
|
||||
|
||||
func driveItemModifiedMillis(item map[string]any) (int64, bool) {
|
||||
for _, k := range []string{"modifiedTime", "modifyTime", "modified_time", "gmtModified", "lastModifiedTime", "updateTime"} {
|
||||
if v, ok := item[k]; ok {
|
||||
if ms, ok := toMillis(v); ok {
|
||||
return ms, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func toMillis(v any) (int64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
if t <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return int64(t), true
|
||||
case json.Number:
|
||||
if n, err := t.Int64(); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if s == "" {
|
||||
return 0, false
|
||||
}
|
||||
if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 {
|
||||
return n, true
|
||||
}
|
||||
if tm, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return tm.UnixMilli(), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// runDriveListLatest 钉盘单层 --latest 扫描。
|
||||
func runDriveListLatest(cmd *cobra.Command, baseArgs map[string]any, rootFolder string, latest int, pattern string, quiet bool) error {
|
||||
buildArgs := func(pageToken string) map[string]any {
|
||||
args := map[string]any{
|
||||
"maxResults": float64(driveDepthPageSize),
|
||||
"orderBy": "modifyTime",
|
||||
"order": "desc",
|
||||
}
|
||||
for k, v := range baseArgs {
|
||||
args[k] = v
|
||||
}
|
||||
if rootFolder != "" {
|
||||
args["parentId"] = rootFolder
|
||||
}
|
||||
if pageToken != "" {
|
||||
args["nextToken"] = pageToken
|
||||
}
|
||||
return args
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"tool": "list_files",
|
||||
"args": buildArgs(""),
|
||||
"latest": latest,
|
||||
"note": "dry-run:latest 为客户端能力,凑够 N 条即停,最多扫描 1000 条",
|
||||
})
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
collected := make([]map[string]any, 0, latest)
|
||||
scanned := 0
|
||||
pageToken := ""
|
||||
maxPages := driveLatestScanMax/driveDepthPageSize + 1
|
||||
for pages := 0; pages < maxPages; pages++ {
|
||||
text, err := callMCPToolReturnText(ctx, "list_files", buildArgs(pageToken))
|
||||
if err != nil {
|
||||
return fmt.Errorf("latest 扫描第 %d 页失败: %w", pages+1, err)
|
||||
}
|
||||
items, next, _ := parseDriveDepthPage(text)
|
||||
for _, item := range items {
|
||||
scanned++
|
||||
if isDriveDepthFolder(item) {
|
||||
continue
|
||||
}
|
||||
name, _ := item["name"].(string)
|
||||
if name == "" {
|
||||
name, _ = item["fileName"].(string)
|
||||
}
|
||||
if pattern != "" && !matchDriveNamePattern(name, pattern) {
|
||||
continue
|
||||
}
|
||||
collected = append(collected, item)
|
||||
if len(collected) >= latest {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(collected) >= latest || next == "" || scanned >= driveLatestScanMax {
|
||||
break
|
||||
}
|
||||
pageToken = next
|
||||
if !quiet {
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] latest 扫描中: 已扫 %d 条,命中 %d/%d\n", scanned, len(collected), latest)
|
||||
}
|
||||
}
|
||||
if len(collected) < latest {
|
||||
hint := fmt.Sprintf("dws drive list --folder <子目录ID> --latest %d", latest)
|
||||
if pattern != "" {
|
||||
hint = fmt.Sprintf("dws drive list --folder <子目录ID> --pattern %q --latest %d", pattern, latest)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[drive-list] 已扫描 %d 条,找到 %d/%d 条;建议缩小范围:%s\n", scanned, len(collected), latest, hint)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{"items": collected})
|
||||
}
|
||||
+495
-3
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -195,7 +196,72 @@ func newMailCommand() *cobra.Command {
|
||||
|
||||
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
|
||||
mailboxSharedWithMeCmd := &cobra.Command{
|
||||
Use: "shared-with-me",
|
||||
Short: "查询共享给我的邮箱",
|
||||
Long: `查询他人共享给当前用户的邮箱账号列表,包含共享关系类型。
|
||||
|
||||
共享关系(relationships)取值:
|
||||
LOGIN 登录(可登录该共享邮箱)
|
||||
SEND_AS 代发(以该邮箱身份发送邮件)
|
||||
SEND_ON_BEHALF 代表发送(代表该邮箱发送邮件)
|
||||
|
||||
返回字段:
|
||||
total 可访问的共享账号总数
|
||||
targets 可访问的共享账号列表`,
|
||||
Example: ` dws mail mailbox shared-with-me
|
||||
dws mail mailbox shared-with-me --limit 20 --offset 0`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ := cmd.Flags().GetInt("limit")
|
||||
toolArgs["limit"] = limit
|
||||
}
|
||||
if cmd.Flags().Changed("offset") {
|
||||
offset, _ := cmd.Flags().GetInt("offset")
|
||||
toolArgs["offset"] = offset
|
||||
}
|
||||
if len(toolArgs) == 0 {
|
||||
return callMCPTool("list_shared_with_me", nil)
|
||||
}
|
||||
return callMCPTool("list_shared_with_me", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(mailboxSharedWithMeCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_shared_with_me",
|
||||
CanonicalPath: "mail.list_shared_with_me",
|
||||
CLIPath: "mail mailbox shared-with-me",
|
||||
PrimaryCLIPath: "mail mailbox shared-with-me",
|
||||
},
|
||||
Description: "查询共享给我的邮箱",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_shared_with_me"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询他人共享给当前用户的邮箱",
|
||||
UseWhen: []string{"需要列出登录/代发/代表发送权限的共享邮箱时"},
|
||||
AvoidWhen: []string{"列出自己邮箱用 mail mailbox list"},
|
||||
Examples: []string{"dws mail mailbox shared-with-me"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "limit", Property: "limit", InterfaceType: "integer"},
|
||||
{Name: "offset", Property: "offset", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
mailboxSharedWithMeCmd.Flags().Int("limit", 0, "返回数量上限 (可选)")
|
||||
mailboxSharedWithMeCmd.Flags().Int("offset", 0, "偏移量 (可选)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd, mailboxSharedWithMeCmd)
|
||||
|
||||
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -2126,9 +2192,256 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
|
||||
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
|
||||
|
||||
messageExportCmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "导出/备份邮件(EML格式)",
|
||||
Long: `导出指定邮件为 EML 格式文件并保存到本地。
|
||||
|
||||
不指定 --filename 时,默认以邮件主题作为文件名。
|
||||
文件保存在当前工作目录下,扩展名为 .eml。
|
||||
默认不覆盖同名文件,使用 --overwrite 强制覆盖。
|
||||
|
||||
注意:目前仅支持 100KB 以内的邮件导出。
|
||||
|
||||
编排流程:
|
||||
1. 调用 get_email_by_message_id 获取邮件主题(用作默认文件名)
|
||||
2. 调用 export_message_mime 获取 EML 内容
|
||||
3. 将 EML 内容原子写入本地文件`,
|
||||
Example: ` dws mail message export --email user@company.com --id <messageId>
|
||||
dws mail message export --email user@company.com --id <messageId> --filename my-mail`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
email := mustGetFlag(cmd, "email")
|
||||
messageID := mustGetFlag(cmd, "id")
|
||||
filename := mustGetFlag(cmd, "filename")
|
||||
overwrite, _ := cmd.Flags().GetBool("overwrite")
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||
deps.Out.PrintKeyValue("操作", "导出邮件为 EML 文件")
|
||||
deps.Out.PrintKeyValue("email", email)
|
||||
deps.Out.PrintKeyValue("messageId", messageID)
|
||||
if filename != "" {
|
||||
deps.Out.PrintKeyValue("filename", filename)
|
||||
}
|
||||
deps.Out.PrintKeyValue("overwrite", fmt.Sprintf("%v", overwrite))
|
||||
deps.Out.PrintKeyValue("编排", "get_email_by_message_id → export_message_mime → 写入本地 .eml 文件")
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
if filename == "" {
|
||||
msgText, err := callMCPToolReturnText(ctx, "get_email_by_message_id", map[string]any{
|
||||
"email": email,
|
||||
"messageId": messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取邮件信息失败: %w", err)
|
||||
}
|
||||
var msgData map[string]any
|
||||
if err := json.Unmarshal([]byte(msgText), &msgData); err == nil {
|
||||
data := msgData
|
||||
if result, ok := data["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
if msg, ok := data["message"].(map[string]any); ok {
|
||||
data = msg
|
||||
}
|
||||
if subj, ok := data["subject"].(string); ok && subj != "" {
|
||||
filename = subj
|
||||
}
|
||||
}
|
||||
if filename == "" {
|
||||
filename = messageID
|
||||
}
|
||||
}
|
||||
filename = sanitizeMailFilename(filename)
|
||||
|
||||
exportText, err := callMCPToolReturnText(ctx, "export_message_mime", map[string]any{
|
||||
"email": email,
|
||||
"id": messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("导出邮件失败: %w", err)
|
||||
}
|
||||
var exportData map[string]any
|
||||
if err := json.Unmarshal([]byte(exportText), &exportData); err != nil {
|
||||
return fmt.Errorf("解析导出结果失败: %w", err)
|
||||
}
|
||||
if result, ok := exportData["result"].(map[string]any); ok {
|
||||
exportData = result
|
||||
}
|
||||
emlContent, _ := exportData["emlContent"].(string)
|
||||
if emlContent == "" {
|
||||
return fmt.Errorf("导出结果为空: %s", exportText)
|
||||
}
|
||||
destPath := filename + ".eml"
|
||||
if err := atomicWriteFile(destPath, []byte(emlContent), 0600, overwrite); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return fmt.Errorf("文件 %s 已存在,使用 --overwrite 覆盖", destPath)
|
||||
}
|
||||
return fmt.Errorf("保存文件失败: %w", err)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("邮件已导出到: %s", destPath))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(messageExportCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "export_message_mime",
|
||||
CanonicalPath: "mail.export_message_mime",
|
||||
CLIPath: "mail message export",
|
||||
PrimaryCLIPath: "mail message export",
|
||||
},
|
||||
Description: "导出/备份邮件为本地 EML 文件",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Orchestrates get_email_by_message_id + export_message_mime + local file write.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "导出邮件为本地 EML 备份",
|
||||
UseWhen: []string{"需要把单封邮件备份为本地 .eml 文件时"},
|
||||
AvoidWhen: []string{"仅查看正文用 mail message get;分享到 IM 用 mail message share-to-chat"},
|
||||
Examples: []string{"dws mail message export --email user@company.com --id <messageId>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "filename", Property: "filename"},
|
||||
{Name: "overwrite", Property: "overwrite", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
messageExportCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
messageExportCmd.Flags().String("id", "", "邮件ID (必填)")
|
||||
messageExportCmd.Flags().String("filename", "", "导出文件名(不含扩展名),默认使用邮件主题")
|
||||
messageExportCmd.Flags().Bool("overwrite", false, "是否覆盖同名文件,默认 false")
|
||||
|
||||
messageShareToChatCmd := &cobra.Command{
|
||||
Use: "share-to-chat",
|
||||
Short: "[危险] 分享邮件至IM聊天",
|
||||
Long: `将指定邮件分享到钉钉单聊。
|
||||
|
||||
参数说明:
|
||||
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
|
||||
--yes 确认执行此危险操作 (必填)
|
||||
|
||||
默认需要 --yes 确认才能执行;--dry-run 仅预览分享计划,不发起真实请求。
|
||||
服务端可能返回风险提示(riskMessage)和 sign;在 --yes 已通过的前提下,
|
||||
将展示风险提示并自动携带 sign 重新请求。`,
|
||||
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2 --yes
|
||||
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1 --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
mcpArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": mustGetFlag(cmd, "id"),
|
||||
}
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
mcpArgs["uids"] = parseRecipients(users)
|
||||
}
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
if deps.Caller.DryRun() {
|
||||
// Human plan summary (no "[DRY-RUN]" tag): Schema dry-run
|
||||
// evidence classifies "操作:" + audited DryRun() as plan.
|
||||
deps.Out.PrintKeyValue("操作", "分享邮件至 IM 聊天")
|
||||
deps.Out.PrintKeyValue("email", mustGetFlag(cmd, "email"))
|
||||
deps.Out.PrintKeyValue("messageId", mustGetFlag(cmd, "id"))
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
deps.Out.PrintKeyValue("users", users)
|
||||
}
|
||||
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", yes))
|
||||
deps.Out.PrintKeyValue("说明", "仅预览分享计划,不发起真实分享请求")
|
||||
return nil
|
||||
}
|
||||
if !commandBoolFlag(cmd, "yes") {
|
||||
return apperrors.NewValidation(
|
||||
"分享邮件至 IM 为高风险操作;获得用户确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先确认目标用户与邮件内容;用户明确同意后以相同参数追加 --yes"),
|
||||
apperrors.WithActions("确认目标用户与邮件", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
ctx := cmd.Context()
|
||||
firstText, err := callMCPToolReturnText(ctx, "share_message_to_chat", mcpArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("分享邮件失败: %w", err)
|
||||
}
|
||||
var firstResult map[string]any
|
||||
if err := json.Unmarshal([]byte(firstText), &firstResult); err != nil {
|
||||
return fmt.Errorf("解析分享结果失败: %w", err)
|
||||
}
|
||||
if result, ok := firstResult["result"].(map[string]any); ok {
|
||||
firstResult = result
|
||||
}
|
||||
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
|
||||
if riskMsg, _ := firstResult["riskMessage"].(string); riskMsg != "" {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
|
||||
}
|
||||
mcpArgs["sign"] = sign
|
||||
return callMCPTool("share_message_to_chat", mcpArgs)
|
||||
}
|
||||
// firstResult is already a parsed object (possibly unwrapped from result).
|
||||
return deps.Out.PrintJSON(firstResult)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(messageShareToChatCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "share_message_to_chat",
|
||||
CanonicalPath: "mail.share_message_to_chat",
|
||||
CLIPath: "mail message share-to-chat",
|
||||
PrimaryCLIPath: "mail message share-to-chat",
|
||||
},
|
||||
Description: "分享邮件至 IM 单聊",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "share_message_to_chat"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "把邮件分享到钉钉单聊",
|
||||
UseWhen: []string{"需要将指定邮件分享给钉钉用户(单聊)时"},
|
||||
AvoidWhen: []string{"仅导出本地备份用 mail message export;群聊发消息用 chat message send"},
|
||||
Examples: []string{"dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "users", Property: "uids"},
|
||||
{Name: "yes", Property: "yes", InterfaceType: "boolean"},
|
||||
},
|
||||
},
|
||||
})
|
||||
messageShareToChatCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
messageShareToChatCmd.Flags().String("id", "", "邮件ID (必填)")
|
||||
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
|
||||
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
|
||||
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
|
||||
messageShareToChatCmd.Flags().Bool("yes", false, "确认执行此危险操作 (必填)")
|
||||
|
||||
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
|
||||
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd, messageExportCmd, messageShareToChatCmd)
|
||||
|
||||
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -3400,7 +3713,129 @@ object 与 operation 合法组合:
|
||||
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
|
||||
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
|
||||
calendarCmd := &cobra.Command{Use: "calendar", Short: "邮箱日历管理", RunE: groupRunE}
|
||||
calendarListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出用户可访问的日历列表",
|
||||
Long: `列出用户可访问的日历列表,包括用户自己创建以及接受共享后生成的日历。
|
||||
返回的 id 可作为 calendar-event list 的 --id / --folder-id 使用。`,
|
||||
Example: ` dws mail calendar list --email user@company.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("list_mailbox_calendars", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(calendarListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_mailbox_calendars",
|
||||
CanonicalPath: "mail.list_mailbox_calendars",
|
||||
CLIPath: "mail calendar list",
|
||||
PrimaryCLIPath: "mail calendar list",
|
||||
},
|
||||
Description: "列出邮箱日历文件夹",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendars"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出邮箱可访问的日历文件夹",
|
||||
UseWhen: []string{"查询邮箱日历文件夹 id,以便继续查日程时"},
|
||||
AvoidWhen: []string{"钉钉主日历日程请用 dws calendar event list"},
|
||||
Examples: []string{"dws mail calendar list --email user@company.com"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
calendarListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarCmd.AddCommand(calendarListCmd)
|
||||
|
||||
calendarEventCmd := &cobra.Command{Use: "calendar-event", Short: "邮箱日历日程管理", RunE: groupRunE}
|
||||
calendarEventListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询指定日历时间范围内的日程",
|
||||
Long: `查询指定邮箱日历文件夹在 UTC 时间区间 [startTime, endTime) 内出现的日程,支持 cursor 分页。循环日程会展开为该时间范围内的单次日程。`,
|
||||
Example: ` dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z"
|
||||
dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start "2026-07-01T00:00:00Z" --end "2026-07-31T23:59:59Z" --cursor <cursor>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "start", "end"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "folder-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": flagOrFallback(cmd, "id", "folder-id"),
|
||||
"startTime": flagOrFallback(cmd, "start", "start-time"),
|
||||
"endTime": flagOrFallback(cmd, "end", "end-time"),
|
||||
}
|
||||
if cursor := mustGetFlag(cmd, "cursor"); cursor != "" {
|
||||
toolArgs["cursor"] = cursor
|
||||
}
|
||||
return callMCPTool("list_mailbox_calendar_events", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(calendarEventListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mail",
|
||||
Name: "list_mailbox_calendar_events",
|
||||
CanonicalPath: "mail.list_mailbox_calendar_events",
|
||||
CLIPath: "mail calendar-event list",
|
||||
PrimaryCLIPath: "mail calendar-event list",
|
||||
},
|
||||
Description: "查询邮箱日历文件夹时间范围内的日程",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "mail", RPCName: "list_mailbox_calendar_events"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询邮箱日历日程列表",
|
||||
UseWhen: []string{"已知邮箱日历文件夹 id,需要按 UTC 时间窗列出日程时"},
|
||||
AvoidWhen: []string{"钉钉主日历请用 dws calendar event list;未知文件夹 id 时先 mail calendar list"},
|
||||
Examples: []string{"dws mail calendar-event list --email user@company.com --id <calendarFolderId> --start \"2026-07-01T00:00:00Z\" --end \"2026-07-31T23:59:59Z\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "email", Property: "email", Required: boolPtr(true)},
|
||||
{Name: "id", Property: "id", Required: boolPtr(true)},
|
||||
{Name: "start", Property: "startTime", Required: boolPtr(true)},
|
||||
{Name: "end", Property: "endTime", Required: boolPtr(true)},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
},
|
||||
},
|
||||
})
|
||||
calendarEventListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
calendarEventListCmd.Flags().String("id", "", "日历文件夹ID (必填)")
|
||||
calendarEventListCmd.Flags().String("folder-id", "", "--id 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("folder-id")
|
||||
calendarEventListCmd.Flags().String("start", "", "视图开始UTC时间 (必填)")
|
||||
calendarEventListCmd.Flags().String("start-time", "", "--start 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("start-time")
|
||||
calendarEventListCmd.Flags().String("end", "", "视图结束UTC时间 (必填)")
|
||||
calendarEventListCmd.Flags().String("end-time", "", "--end 的别名")
|
||||
_ = calendarEventListCmd.Flags().MarkHidden("end-time")
|
||||
calendarEventListCmd.Flags().String("cursor", "", "分页光标 (可选)")
|
||||
calendarEventCmd.AddCommand(calendarEventListCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd, calendarCmd, calendarEventCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
@@ -3935,3 +4370,60 @@ func runMailAttachmentDownload(cmd *cobra.Command) error {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("附件已保存到: %s", destPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeMailFilename(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.ReplaceAll(name, "/", "_")
|
||||
name = strings.ReplaceAll(name, "\\", "_")
|
||||
name = strings.ReplaceAll(name, "\x00", "")
|
||||
if name == "" {
|
||||
return "mail"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// mailAtomicLink is the no-clobber commit for atomicWriteFile (test-injectable).
|
||||
var mailAtomicLink = os.Link
|
||||
|
||||
// atomicWriteFile 原子写入文件:先写同目录临时文件,成功后提交到目标路径。
|
||||
// overwrite=false 使用 link(2) 实现存在即失败;overwrite=true 使用 rename 覆盖。
|
||||
func atomicWriteFile(path string, data []byte, perm os.FileMode, overwrite bool) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := atomicCreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建临时文件失败: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
_ = tmp.Close()
|
||||
_ = atomicRemove(tmpName)
|
||||
}
|
||||
}()
|
||||
if err := tmp.Chmod(perm); err != nil {
|
||||
return fmt.Errorf("设置文件权限失败: %w", err)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
return fmt.Errorf("写入数据失败: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
return fmt.Errorf("同步磁盘失败: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("关闭临时文件失败: %w", err)
|
||||
}
|
||||
if overwrite {
|
||||
if err := atomicRename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("重命名文件失败: %w", err)
|
||||
}
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
if err := mailAtomicLink(tmpName, path); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = atomicRemove(tmpName)
|
||||
success = true
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -49,12 +49,13 @@ func newMarkdownCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "markdown",
|
||||
Short: "Markdown 文件处理",
|
||||
Long: "创建、覆盖、修补和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
Long: "创建、覆盖、修补、对比和获取钉盘或文档空间中的原生 Markdown 文件。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
root.AddCommand(
|
||||
newMarkdownFetchCmd(),
|
||||
newMarkdownCreateCmd(),
|
||||
newMarkdownDiffCmd(),
|
||||
newMarkdownOverwriteCmd(),
|
||||
newMarkdownPatchCmd(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws markdown diff
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// diffResult 是 diff 命令的输出结构。
|
||||
type diffResult struct {
|
||||
Mode string `json:"mode"`
|
||||
Changed bool `json:"changed"`
|
||||
AddedLines int `json:"added_lines"`
|
||||
DeletedLines int `json:"deleted_lines"`
|
||||
Hunks int `json:"hunks"`
|
||||
Diff string `json:"diff"`
|
||||
}
|
||||
|
||||
// diff 命令的限制(包级变量以便覆盖测试注入更小阈值)。
|
||||
var (
|
||||
maxDiffFileSize int64 = 10 * 1024 * 1024 // 单侧文件大小上限 10MB
|
||||
diffDownloadTimeout = 10 * time.Minute // 下载远端内容超时(与项目其他下载命令一致)
|
||||
diffComputeTimeout = 30 * time.Second // 本地 diff 计算超时
|
||||
diffJSONMarshalIndent = json.MarshalIndent
|
||||
runMarkdownUnifiedDiff = computeUnifiedDiff
|
||||
)
|
||||
|
||||
// formatFileSize 返回人类可读的文件大小。
|
||||
func formatFileSize(size int64) string {
|
||||
if size >= 1024*1024 {
|
||||
return fmt.Sprintf("%.1f MB", float64(size)/float64(1024*1024))
|
||||
}
|
||||
if size >= 1024 {
|
||||
return fmt.Sprintf("%.1f KB", float64(size)/float64(1024))
|
||||
}
|
||||
return fmt.Sprintf("%d B", size)
|
||||
}
|
||||
|
||||
// checkFileSize 校验文件大小是否超过限制。
|
||||
func checkFileSize(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Size() > maxDiffFileSize {
|
||||
return fmt.Errorf("文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(info.Size()), formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadRemoteContent 下载远端文件内容并返回其文本。
|
||||
// versionNum <= 0 时走 download_file(用 fileId),> 0 时走 download_file_version(用 nodeId + version)。
|
||||
func downloadRemoteContent(ctx context.Context, fileID string, versionNum int) (string, error) {
|
||||
var text string
|
||||
var err error
|
||||
if versionNum > 0 {
|
||||
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file_version", map[string]any{
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
})
|
||||
} else {
|
||||
text, err = callMCPToolReturnTextOnServer(ctx, "drive", "download_file", map[string]any{
|
||||
"fileId": fileID,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resourceURL, dlHeaders, err := parseDownloadInfo(text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// 下载时即限制大小,避免完整下载超大文件后才拦截(约束网络流量与内存占用)
|
||||
content, err := diffDownloadLimited(ctx, resourceURL, dlHeaders)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// diffDownloadLimited 下载远端内容,并在下载过程中强制执行 maxDiffFileSize 上限。
|
||||
// 包级变量以便测试注入。
|
||||
var diffDownloadLimited = defaultDiffDownloadLimited
|
||||
|
||||
// defaultDiffDownloadLimited 通过 HTTP GET 下载内容:先用 Content-Length 预检,
|
||||
// 再用 io.LimitReader 将实际读取量限制为 maxDiffFileSize+1 字节,超限即报错,
|
||||
// 从而约束网络流量与内存占用,而非在完整下载后才校验。
|
||||
func defaultDiffDownloadLimited(ctx context.Context, url string, headers map[string]string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: diffDownloadTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Content-Length 预检:可在读取 body 前提前拦截超大文件
|
||||
if resp.ContentLength > maxDiffFileSize {
|
||||
return nil, fmt.Errorf("远端文件大小 %s 超过限制 %s,请使用更小的文件", formatFileSize(resp.ContentLength), formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
|
||||
// 实际读取限制为 maxDiffFileSize+1 字节,读满即判定超限(防止 Content-Length 缺失或造假)
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDiffFileSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxDiffFileSize {
|
||||
return nil, fmt.Errorf("远端文件大小超过限制 %s,请使用更小的文件", formatFileSize(maxDiffFileSize))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// computeUnifiedDiff 使用 Go stdlib patience diff 算法计算 unified diff,并统计变更行数。
|
||||
func computeUnifiedDiff(left, right string, contextLines int) (string, int, int, int, bool) {
|
||||
out := UnifiedDiff("left", []byte(left), "right", []byte(right), contextLines)
|
||||
if len(out) == 0 {
|
||||
return "", 0, 0, 0, false
|
||||
}
|
||||
|
||||
text := string(out)
|
||||
added, deleted, hunks := 0, 0, 0
|
||||
// 只统计首个 @@ 之后的 hunk 区行:头部三行(diff/---/+++)不参与计数,
|
||||
// hunk 区内每行必带单字符前缀,内容行以 --/++ 开头也不会被误判为文件头而漏计
|
||||
inHunk := false
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if strings.HasPrefix(line, "@@") {
|
||||
hunks++
|
||||
inHunk = true
|
||||
continue
|
||||
}
|
||||
if !inHunk {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "+") {
|
||||
added++
|
||||
} else if strings.HasPrefix(line, "-") {
|
||||
deleted++
|
||||
}
|
||||
}
|
||||
changed := added > 0 || deleted > 0
|
||||
return text, added, deleted, hunks, changed
|
||||
}
|
||||
|
||||
// ensureMarkdownDiffType 校验 markdown diff 的目标文件类型。
|
||||
// markdown 产品域面向 .md 文件,非 md 文件拦截并回引到对应产品命令。
|
||||
// 类型探测复用 fetchFileInfo(显式路由 drive server 的 get_file_info),
|
||||
// 探测失败或类型未知时不阻断,让后续 MCP 工具自行报错。
|
||||
func ensureMarkdownDiffType(ctx context.Context, nodeID string) error {
|
||||
info := fetchFileInfo(ctx, nodeID)
|
||||
switch info.extension {
|
||||
case "", "md", "markdown":
|
||||
return nil
|
||||
case "adoc":
|
||||
return fmt.Errorf("该文件为钉钉在线文档 (adoc),不支持 markdown diff\n请使用 dws doc 对应命令(如 dws doc version list / dws doc export)")
|
||||
case "axls":
|
||||
return fmt.Errorf("该文件为钉钉在线表格 (axls),不支持 markdown diff\n请使用 dws sheet 对应命令")
|
||||
case "amind", "adraw":
|
||||
return fmt.Errorf("该文件为钉钉在线%s (%s),暂不支持历史版本管理\nmarkdown diff 与 dws drive list --versions / dws drive download --version 均不支持该类型", describeDingTalkDocType(info.extension), info.extension)
|
||||
default:
|
||||
return fmt.Errorf("该文件为 %s 文件,markdown diff 仅支持 .md 文件\n普通文件的历史版本请使用 dws drive list --versions / dws drive download --version / dws drive revert", info.extension)
|
||||
}
|
||||
}
|
||||
|
||||
// newMarkdownDiffCmd 创建 markdown diff 子命令。
|
||||
func newMarkdownDiffCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "diff",
|
||||
Short: "比较 Markdown 内容差异",
|
||||
Long: `比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff。
|
||||
|
||||
模式:
|
||||
remote_vs_remote: --version V1 --version2 V2 (两个历史版本)
|
||||
remote_vs_remote: --version V1 (历史版本 vs 最新)
|
||||
remote_vs_local: --file ./local.md (最新 vs 本地)
|
||||
remote_vs_local: --version V1 --file ./local.md (历史版本 vs 本地)
|
||||
|
||||
历史版本号通过 dws drive list --versions 获取。
|
||||
--file 与 --version2 不能同时使用。
|
||||
|
||||
限制:
|
||||
- 仅支持 .md 文件(在线文档/表格请使用 doc/sheet 命令)
|
||||
- 单侧文件大小上限: 10 MB
|
||||
- 下载超时: 10 分钟
|
||||
- diff 计算超时: 30 秒`,
|
||||
Example: ` # 比较两个历史版本
|
||||
dws markdown diff --node <dentryUuid> --version 3 --version2 5
|
||||
|
||||
# 历史版本 vs 最新版本
|
||||
dws markdown diff --node <dentryUuid> --version 3
|
||||
|
||||
# 最新版本 vs 本地文件
|
||||
dws markdown diff --node <dentryUuid> --file ./draft.md
|
||||
|
||||
# 历史版本 vs 本地文件
|
||||
dws markdown diff --node <dentryUuid> --version 3 --file ./draft.md`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
version1, _ := cmd.Flags().GetInt("version")
|
||||
version2, _ := cmd.Flags().GetInt("version2")
|
||||
localFile, _ := cmd.Flags().GetString("file")
|
||||
contextLines, _ := cmd.Flags().GetInt("context")
|
||||
|
||||
// fail-fast 参数校验(置于 dry-run 之前):显式传了版本号但非正整数时立即报错,
|
||||
// 避免静默降级为“最新版本”;--context 允许 0(无上下文),仅拒绝负值
|
||||
if cmd.Flags().Changed("version") && version1 <= 0 {
|
||||
return fmt.Errorf("--version 必须为正整数,当前值: %d", version1)
|
||||
}
|
||||
if cmd.Flags().Changed("version2") && version2 <= 0 {
|
||||
return fmt.Errorf("--version2 必须为正整数,当前值: %d", version2)
|
||||
}
|
||||
if contextLines < 0 {
|
||||
return fmt.Errorf("--context 不能为负数,当前值: %d", contextLines)
|
||||
}
|
||||
|
||||
// 互斥校验:--file 与 --version2 不能同时使用
|
||||
if localFile != "" && version2 > 0 {
|
||||
return fmt.Errorf("--file 与 --version2 不能同时使用")
|
||||
}
|
||||
|
||||
// 模式判定
|
||||
var mode string
|
||||
if localFile != "" {
|
||||
mode = "remote_vs_local"
|
||||
} else {
|
||||
mode = "remote_vs_remote"
|
||||
}
|
||||
|
||||
// remote_vs_remote 模式至少需要一个版本号,否则两侧均取最新版本,diff 必为空
|
||||
if localFile == "" && version1 == 0 && version2 == 0 {
|
||||
return fmt.Errorf("remote_vs_remote 模式至少需要指定 --version 或 --version2 之一")
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "Markdown 内容 Diff")
|
||||
deps.Out.PrintKeyValue("模式", mode)
|
||||
deps.Out.PrintKeyValue("节点ID", nodeID)
|
||||
if version1 > 0 {
|
||||
deps.Out.PrintKeyValue("左侧版本", fmt.Sprintf("%d", version1))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("左侧版本", "最新")
|
||||
}
|
||||
if localFile != "" {
|
||||
deps.Out.PrintKeyValue("右侧", localFile)
|
||||
} else if version2 > 0 {
|
||||
deps.Out.PrintKeyValue("右侧版本", fmt.Sprintf("%d", version2))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("右侧版本", "最新")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 下载超时 context(与项目其他下载命令一致:10 分钟)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), diffDownloadTimeout)
|
||||
defer cancel()
|
||||
|
||||
// 类型守卫置于 dry-run 之后,确保 dry-run 不产生任何服务端调用
|
||||
if err := ensureMarkdownDiffType(ctx, nodeID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remote_vs_local 模式:先校验本地文件大小,避免下载后才发现过大
|
||||
if localFile != "" {
|
||||
if err := checkFileSize(localFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 输出格式读全局 --format(默认 json),json 时输出结构化结果,其余输出文本摘要
|
||||
isJSON := deps.Caller.Format() == "json"
|
||||
// 进度属带外诊断信息,统一写入 stderr,保证 stdout 在两种模式下都是纯净输出
|
||||
progress := func(msg string) { fmt.Fprintln(os.Stderr, msg) }
|
||||
|
||||
// 下载左侧内容
|
||||
progress("[1/3] 获取左侧内容...")
|
||||
leftContent, err := downloadRemoteContent(ctx, nodeID, version1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 获取右侧内容
|
||||
var rightContent string
|
||||
if localFile != "" {
|
||||
// remote_vs_local: 读取本地文件(大小已校验)
|
||||
progress("[2/3] 读取本地文件...")
|
||||
data, err := os.ReadFile(localFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取本地文件失败: %w", err)
|
||||
}
|
||||
rightContent = string(data)
|
||||
} else {
|
||||
// remote_vs_remote: 下载右侧远端内容
|
||||
progress("[2/3] 获取右侧内容...")
|
||||
rightContent, err = downloadRemoteContent(ctx, nodeID, version2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 diff(带超时保护)
|
||||
progress("[3/3] 计算差异...")
|
||||
type diffOutput struct {
|
||||
text string
|
||||
added int
|
||||
deleted int
|
||||
hunks int
|
||||
changed bool
|
||||
}
|
||||
resultCh := make(chan diffOutput, 1)
|
||||
// Capture seams before the goroutine so test restorers cannot race
|
||||
// against a still-running compute after the timeout path returns.
|
||||
computeDiff := runMarkdownUnifiedDiff
|
||||
marshalIndent := diffJSONMarshalIndent
|
||||
go func() {
|
||||
text, added, deleted, hunks, changed := computeDiff(leftContent, rightContent, contextLines)
|
||||
resultCh <- diffOutput{text, added, deleted, hunks, changed}
|
||||
}()
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
diffText, added, deleted, hunks, changed := res.text, res.added, res.deleted, res.hunks, res.changed
|
||||
result := diffResult{
|
||||
Mode: mode,
|
||||
Changed: changed,
|
||||
AddedLines: added,
|
||||
DeletedLines: deleted,
|
||||
Hunks: hunks,
|
||||
Diff: diffText,
|
||||
}
|
||||
|
||||
// 输出
|
||||
if isJSON {
|
||||
data, err := marshalIndent(result, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON 序列化失败: %w", err)
|
||||
}
|
||||
deps.Out.PrintRaw(string(data))
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("模式", result.Mode)
|
||||
if result.Changed {
|
||||
deps.Out.PrintKeyValue("是否有变更", "是")
|
||||
} else {
|
||||
deps.Out.PrintKeyValue("是否有变更", "否")
|
||||
}
|
||||
deps.Out.PrintKeyValue("新增行数", fmt.Sprintf("%d", result.AddedLines))
|
||||
deps.Out.PrintKeyValue("删除行数", fmt.Sprintf("%d", result.DeletedLines))
|
||||
deps.Out.PrintKeyValue("差异块数", fmt.Sprintf("%d", result.Hunks))
|
||||
if result.Changed {
|
||||
deps.Out.PrintRaw("")
|
||||
deps.Out.PrintRaw(result.Diff)
|
||||
}
|
||||
}
|
||||
case <-time.After(diffComputeTimeout):
|
||||
return fmt.Errorf("diff 计算超时(%s),文件可能过大,请尝试减小 --context 或使用更小的文件", diffComputeTimeout)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().String("node", "", "文件 ID (dentryUuid) 或 URL (必填)")
|
||||
cmd.Flags().Int("version", 0, "左侧历史版本号 (可选,不传=最新版本)")
|
||||
cmd.Flags().Int("version2", 0, "右侧历史版本号 (可选,不传=最新版本;不能与 --file 同时使用)")
|
||||
cmd.Flags().String("file", "", "本地 .md 文件路径 (可选,指定后进入 remote_vs_local 模式)")
|
||||
cmd.Flags().Int("context", 3, "diff 上下文行数 (默认 3)")
|
||||
|
||||
// --node 隐藏别名(与 version/fetch 子命令一致)
|
||||
cmd.Flags().String("url", "", "")
|
||||
cmd.Flags().String("id", "", "")
|
||||
cmd.Flags().String("node-id", "", "")
|
||||
cmd.Flags().String("doc-id", "", "")
|
||||
cmd.Flags().String("file-id", "", "")
|
||||
_ = cmd.Flags().MarkHidden("url")
|
||||
_ = cmd.Flags().MarkHidden("id")
|
||||
_ = cmd.Flags().MarkHidden("node-id")
|
||||
_ = cmd.Flags().MarkHidden("doc-id")
|
||||
_ = cmd.Flags().MarkHidden("file-id")
|
||||
|
||||
RegisterCrossProductAliases(cmd)
|
||||
|
||||
cli.AnnotateRuntimeRequiredFlags(cmd, "node")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "markdown",
|
||||
Name: "diff",
|
||||
CanonicalPath: "markdown.diff",
|
||||
CLIPath: "markdown diff",
|
||||
PrimaryCLIPath: "markdown diff",
|
||||
},
|
||||
Description: "比较远端 Markdown 文件的两个版本,或远端版本与本地文件,生成 unified diff",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Local diff workflow: download remote version(s) and/or read a local file, then compute unified diff client-side.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "比较 Markdown 文件版本或本地草稿差异",
|
||||
UseWhen: []string{"需要对比远端 .md 历史版本,或远端最新/历史版本与本地草稿的差异时"},
|
||||
AvoidWhen: []string{"在线文档/表格差异请用 doc/sheet;普通二进制文件版本请用 drive list --versions / drive download --version"},
|
||||
Examples: []string{"dws markdown diff --node <nodeId> --version 3 --version2 5"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId", Required: boolPtr(true)},
|
||||
{Name: "version", Property: "version", InterfaceType: "integer"},
|
||||
{Name: "version2", Property: "version2", InterfaceType: "integer"},
|
||||
{Name: "file", Property: "file"},
|
||||
{Name: "context", Property: "context", InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// fetchFileInfo 通过 get_file_info 获取扩展名(markdown diff 类型守卫用)。
|
||||
// 探测失败返回零值,由调用方决定是否阻断。
|
||||
type markdownFileInfo struct {
|
||||
name string
|
||||
extension string
|
||||
}
|
||||
|
||||
func fetchFileInfo(ctx context.Context, nodeID string) (info markdownFileInfo) {
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{"fileId": nodeID})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var resp map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &resp); err != nil {
|
||||
return
|
||||
}
|
||||
data := resp
|
||||
if result, ok := resp["result"].(map[string]any); ok {
|
||||
data = result
|
||||
}
|
||||
if name, ok := data["name"].(string); ok {
|
||||
info.name = name
|
||||
}
|
||||
if ext, ok := data["extension"].(string); ok {
|
||||
info.extension = strings.ToLower(strings.TrimPrefix(ext, "."))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func describeDingTalkDocType(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case "adoc":
|
||||
return "文档"
|
||||
case "axls":
|
||||
return "表格"
|
||||
case "amind":
|
||||
return "脑图"
|
||||
case "adraw":
|
||||
return "画图"
|
||||
default:
|
||||
return "文件"
|
||||
}
|
||||
}
|
||||
+260
-10
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -18,7 +19,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "minutes",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结及文件上传会话。",
|
||||
AgentSummary: "查询和维护钉钉听记的转写、摘要、待办、权限、录音、标签、说话人总结、语音备忘及文件上传会话。",
|
||||
UseWhen: []string{
|
||||
"用户要查找、读取、编辑或管理钉钉听记及其录音、转写、摘要和衍生内容。",
|
||||
},
|
||||
@@ -1282,7 +1283,10 @@ func newMinutesCommand() *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "添加听记个人热词,用于优化语音识别中专有名词、人名等的识别准确率。",
|
||||
UseWhen: []string{"需要添加听记个人热词以优化专有名词/人名识别时(单词不超过约10汉字)"},
|
||||
AvoidWhen: []string{"要查看已有热词时改用 dws minutes hot-word list"},
|
||||
AvoidWhen: []string{
|
||||
"要查看已有热词时改用 dws minutes hot-word list",
|
||||
"要删除热词时改用 dws minutes hot-word delete",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word add --words \"钉钉\"",
|
||||
"dws minutes hot-word add --words \"OKR,钉钉,Copilot\"",
|
||||
@@ -1328,13 +1332,70 @@ func newMinutesCommand() *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询当前用户配置的所有听记热词列表。",
|
||||
UseWhen: []string{"需要查看当前用户已配置的听记个人热词列表时"},
|
||||
AvoidWhen: []string{"要添加热词时改用 hot-word add"},
|
||||
Examples: []string{"dws minutes hot-word list"},
|
||||
AvoidWhen: []string{
|
||||
"要添加热词时改用 hot-word add",
|
||||
"要删除热词时改用 hot-word delete",
|
||||
},
|
||||
Examples: []string{"dws minutes hot-word list"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd)
|
||||
hotWordDeleteCmd := &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "批量删除个人热词",
|
||||
Long: `批量删除听记个人热词。
|
||||
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
|
||||
Example: ` dws minutes hot-word delete --words "钉钉"
|
||||
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "words"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("delete_personal_hotword", map[string]any{
|
||||
"hotWordList": parseCSVValues(mustGetFlag(cmd, "words")),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(hotWordDeleteCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "delete_personal_hotword",
|
||||
CanonicalPath: "minutes.delete_personal_hotword",
|
||||
CLIPath: "minutes hot-word delete",
|
||||
PrimaryCLIPath: "minutes hot-word delete",
|
||||
},
|
||||
Description: "批量删除听记个人热词。删除后对应热词不再参与后续语音识别优化。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "delete_personal_hotword"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "批量删除听记个人热词,清理误加或过时热词。",
|
||||
UseWhen: []string{"用户要删除/移除已配置的听记个人热词时"},
|
||||
AvoidWhen: []string{
|
||||
"要添加热词时改用 hot-word add",
|
||||
"不确定现有热词时先用 hot-word list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word delete --words \"钉钉\"",
|
||||
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "words", Property: "hotWordList"},
|
||||
},
|
||||
},
|
||||
})
|
||||
hotWordDeleteCmd.Flags().String("words", "", "要删除的热词,多个用逗号分隔 (必填)")
|
||||
|
||||
hotWordCmd.AddCommand(hotWordAddCmd, hotWordListCmd, hotWordDeleteCmd)
|
||||
|
||||
// ── replace-text 命令 ───────────────────────────────────────
|
||||
replaceTextCmd := &cobra.Command{
|
||||
@@ -1618,8 +1679,8 @@ func newMinutesCommand() *cobra.Command {
|
||||
uploadCmd.AddCommand(uploadCreateCmd, uploadCompleteCmd, uploadCancelCmd)
|
||||
|
||||
// ── permission 子组 ─────────────────────────────────────────
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission。
|
||||
// 听记成员权限管理:批量添加/移除成员及其权限、为当前用户申请权限。
|
||||
// 对应 MCP 工具 add_member_permission / remove_member_permission / apply_minutes_permission。
|
||||
permissionCmd := &cobra.Command{Use: "permission", Short: "听记成员权限管理", RunE: groupRunE}
|
||||
|
||||
// permission add — 对应 MCP 工具 add_member_permission
|
||||
@@ -1709,6 +1770,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
UseWhen: []string{"已知听记 uuid,需要批量给听记增加成员并设置权限(policy 0管理员/1所有者/2可编辑/3可查看下载/4仅查看)时"},
|
||||
AvoidWhen: []string{
|
||||
"要移除成员权限时改用 dws minutes permission remove",
|
||||
"当前用户自己申请访问权限时改用 dws minutes permission apply",
|
||||
"成员、权限策略或听记 id 未确认时不要添加",
|
||||
},
|
||||
Examples: []string{
|
||||
@@ -1784,6 +1846,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
UseWhen: []string{"用户明确要求批量移除听记成员权限,使其失去访问时"},
|
||||
AvoidWhen: []string{
|
||||
"要添加权限时改用 permission add",
|
||||
"当前用户自己申请访问权限时改用 permission apply",
|
||||
"成员或听记 id 未确认时不要移除",
|
||||
},
|
||||
Examples: []string{
|
||||
@@ -1803,7 +1866,85 @@ func newMinutesCommand() *cobra.Command {
|
||||
_ = permissionRemoveCmd.Flags().MarkHidden("task-uuids")
|
||||
permissionRemoveCmd.Flags().String("member-uids", "", "成员钉钉 UID 列表,逗号分隔 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd)
|
||||
// permission apply — 对应 MCP 工具 apply_minutes_permission
|
||||
permissionApplyCmd := &cobra.Command{
|
||||
Use: "apply",
|
||||
Short: "为当前用户申请听记权限",
|
||||
Long: `为当前登录用户申请指定听记的权限。
|
||||
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
|
||||
|
||||
权限类型 (--policy):
|
||||
2 = 可编辑
|
||||
3 = 可查看/下载
|
||||
4 = 仅查看`,
|
||||
Example: ` dws minutes permission apply --id <taskUuid> --policy 4
|
||||
dws minutes permission apply --id <taskUuid> --policy 2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "policy"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
|
||||
if err != nil || policyID < 2 || policyID > 4 {
|
||||
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
|
||||
}
|
||||
|
||||
return callMCPTool("apply_minutes_permission", map[string]any{
|
||||
"taskUuid": flagOrFallback(cmd, "id", "url", "task-uuid", "uuid"),
|
||||
"policyId": float64(policyID),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(permissionApplyCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "apply_minutes_permission",
|
||||
CanonicalPath: "minutes.apply_minutes_permission",
|
||||
CLIPath: "minutes permission apply",
|
||||
PrimaryCLIPath: "minutes permission apply",
|
||||
},
|
||||
Description: "为当前登录用户申请指定听记的权限。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "apply_minutes_permission"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为当前登录用户申请指定听记的访问权限(可编辑/可查看下载/仅查看)。",
|
||||
UseWhen: []string{"当前用户对某听记无权限,需要向所有者申请访问(policy 2/3/4)时"},
|
||||
AvoidWhen: []string{
|
||||
"所有者批量给他人加权限时改用 permission add",
|
||||
"要移除他人权限时改用 permission remove",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes permission apply --id <taskUuid> --policy 4",
|
||||
"dws minutes permission apply --id <taskUuid> --policy 2",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "id", Property: "taskUuid"},
|
||||
{Name: "policy", Property: "policyId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
permissionApplyCmd.Flags().String("id", "", "听记 taskUuid (必填)")
|
||||
permissionApplyCmd.Flags().String("url", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("url")
|
||||
permissionApplyCmd.Flags().String("task-uuid", "", "--id 的别名 (兼容 OpenAPI 字段名)")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
|
||||
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
|
||||
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
|
||||
|
||||
// ── tag 子组 ────────────────────────────────────────────────
|
||||
// 听记标签/分组管理:查询用户标签列表、按标签查询听记。
|
||||
@@ -1928,13 +2069,122 @@ func newMinutesCommand() *cobra.Command {
|
||||
|
||||
tagCmd.AddCommand(tagListCmd, tagQueryCmd)
|
||||
|
||||
// ── audio-memo 子组 ────────────────────────────
|
||||
// 语音备忘查询:对应 MCP 工具 list_audio_memos。
|
||||
// 用户身份由网关按登录态注入 uid,agent/CLI 无需传入。
|
||||
// 返回值 items[].audioUrl 为带签名的音频 URL(含 &),因此使用
|
||||
// callMCPToolUnescaped 输出,避免 & 被转义为 \u0026(与 upload 一致)。
|
||||
audioMemoCmd := &cobra.Command{Use: "audio-memo", Short: "语音备忘查询", RunE: groupRunE}
|
||||
|
||||
audioMemoListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查询语音备忘列表",
|
||||
Long: `查询当前用户的语音备忘列表,支持分页和时间范围筛选。
|
||||
分页:首页 --cursor 留空(或 0),后续把上一页返回的 nextCursor 回填到 --cursor。
|
||||
时间范围:--start/--end 为 ISO-8601(可选),不传默认查询近一年。`,
|
||||
Example: ` dws minutes audio-memo list
|
||||
dws minutes audio-memo list --max 500
|
||||
dws minutes audio-memo list --start "2026-01-01T00:00:00+08:00" --end "2026-07-21T23:59:59+08:00"
|
||||
dws minutes audio-memo list --cursor 1740000000000`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
|
||||
max, _ := cmd.Flags().GetFloat64("max")
|
||||
if max <= 0 || max > 1000 {
|
||||
return fmt.Errorf("flag --max must be between 1 and 1000")
|
||||
}
|
||||
toolArgs["pageSize"] = max
|
||||
|
||||
if cmd.Flags().Changed("cursor") {
|
||||
cursor, _ := cmd.Flags().GetInt64("cursor")
|
||||
if cursor < 0 {
|
||||
return fmt.Errorf("flag --cursor must be >= 0")
|
||||
}
|
||||
toolArgs["cursor"] = float64(cursor)
|
||||
}
|
||||
|
||||
startStr, _ := cmd.Flags().GetString("start")
|
||||
endStr, _ := cmd.Flags().GetString("end")
|
||||
// China Standard Time has no DST; FixedZone avoids zoneinfo nil-fallback branches.
|
||||
loc := time.FixedZone("Asia/Shanghai", 8*3600)
|
||||
var startMs, endMs int64
|
||||
if startStr != "" {
|
||||
var err error
|
||||
startMs, err = parseISOTimeToMillis("start", startStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["startTime"] = time.UnixMilli(startMs).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if endStr != "" {
|
||||
var err error
|
||||
endMs, err = parseISOTimeToMillis("end", endStr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["endTime"] = time.UnixMilli(endMs).In(loc).Format(time.RFC3339)
|
||||
}
|
||||
if startStr != "" && endStr != "" {
|
||||
if err := validateTimeRange(startMs, endMs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return callMCPToolUnescaped("list_audio_memos", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(audioMemoListCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "minutes",
|
||||
Name: "list_audio_memos",
|
||||
CanonicalPath: "minutes.list_audio_memos",
|
||||
CLIPath: "minutes audio-memo list",
|
||||
PrimaryCLIPath: "minutes audio-memo list",
|
||||
},
|
||||
Description: "查询当前用户的语音备忘列表,支持分页和时间范围筛选。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "minutes", RPCName: "list_audio_memos"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询当前用户的语音备忘列表(独立于听记列表与 get audio)。",
|
||||
UseWhen: []string{"用户要查看语音备忘/录音备忘列表时(可带时间范围或翻页)"},
|
||||
AvoidWhen: []string{
|
||||
"要查听记列表改用 minutes list",
|
||||
"只要某篇听记的音频地址改用 minutes get audio",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes audio-memo list",
|
||||
"dws minutes audio-memo list --start \"2026-01-01T00:00:00+08:00\" --end \"2026-07-21T23:59:59+08:00\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "max", Property: "pageSize"},
|
||||
{Name: "cursor", Property: "cursor"},
|
||||
{Name: "start", Property: "startTime"},
|
||||
{Name: "end", Property: "endTime"},
|
||||
},
|
||||
},
|
||||
})
|
||||
audioMemoListCmd.Flags().Float64("max", 200, "每页数据条数 (默认 200,上限 1000)")
|
||||
audioMemoListCmd.Flags().Int64("cursor", 0, "翻页游标,回填上一页返回的 nextCursor (首页留空)")
|
||||
audioMemoListCmd.Flags().String("start", "", "开始时间 ISO-8601 (可选,默认近一年)")
|
||||
audioMemoListCmd.Flags().String("end", "", "结束时间 ISO-8601 (可选)")
|
||||
audioMemoCmd.AddCommand(audioMemoListCmd)
|
||||
|
||||
minutesCmd := &cobra.Command{
|
||||
Use: "minutes",
|
||||
Short: "AI 听记 / 会议纪要",
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理,以及修改标题和纪要内容。`,
|
||||
Long: `管理钉钉AI听记:查询列表、获取详情、摘要、转写、待办、关键字、音频地址、思维导图、发言人管理、文件上传、成员权限管理、语音备忘查询,以及修改标题和纪要内容。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
minutesCmd.AddCommand(minutesListCmd, minutesGetCmd, minutesUpdateCmd, minutesRecordCmd, mindGraphCmd, speakerCmd, hotWordCmd, replaceTextCmd, audioMemoCmd, uploadCmd, permissionCmd, tagCmd)
|
||||
return minutesCmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeMarkdownDiff(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "markdown"}, args...)
|
||||
root := newMarkdownCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownDiffCommand(t *testing.T) {
|
||||
t.Run("oversized local file", func(t *testing.T) {
|
||||
testseam.Swap(t, &maxDiffFileSize, int64(8))
|
||||
big := filepath.Join(t.TempDir(), "big.md")
|
||||
if err := os.WriteFile(big, []byte("0123456789"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkFileSize(big); err == nil {
|
||||
t.Fatal("expected oversized local file")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("download helpers and ensure type", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("left\n"), nil
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a","headers":{"X":"1"}}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err != nil {
|
||||
t.Fatalf("download latest: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a"}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "nid", 3); err != nil {
|
||||
t.Fatalf("download version: %v", err)
|
||||
}
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return nil, errors.New("dl boom")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"resourceUrl":"https://example.test/a"}`},
|
||||
}})
|
||||
if _, err := downloadRemoteContent(context.Background(), "fid", 0); err == nil {
|
||||
t.Fatal("expected download limited error")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
ext string
|
||||
ok bool
|
||||
}{
|
||||
{"md", true}, {"markdown", true}, {"", true},
|
||||
{"adoc", false}, {"axls", false}, {"amind", false}, {"adraw", false}, {"pdf", false},
|
||||
} {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: fmt.Sprintf(`{"result":{"name":"x","extension":%q}}`, tc.ext)},
|
||||
}})
|
||||
err := ensureMarkdownDiffType(context.Background(), "n1")
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("ext %q: %v", tc.ext, err)
|
||||
}
|
||||
if !tc.ok && err == nil {
|
||||
t.Fatalf("ext %q: expected type error", tc.ext)
|
||||
}
|
||||
}
|
||||
for _, ext := range []string{"adoc", "axls", "amind", "adraw", "other"} {
|
||||
if describeDingTalkDocType(ext) == "" {
|
||||
t.Fatalf("describe %q empty", ext)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaultDiffDownloadLimited http paths", func(t *testing.T) {
|
||||
okSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "4")
|
||||
_, _ = w.Write([]byte("data"))
|
||||
}))
|
||||
t.Cleanup(okSrv.Close)
|
||||
got, err := defaultDiffDownloadLimited(context.Background(), okSrv.URL, map[string]string{"X-Test": "1"})
|
||||
if err != nil || string(got) != "data" {
|
||||
t.Fatalf("ok download: %v %q", err, got)
|
||||
}
|
||||
|
||||
badStatus := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte("nope"))
|
||||
}))
|
||||
t.Cleanup(badStatus.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), badStatus.URL, nil); err == nil {
|
||||
t.Fatal("expected non-200")
|
||||
}
|
||||
|
||||
testseam.Swap(t, &maxDiffFileSize, int64(3))
|
||||
tooBigHeader := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Length", "100")
|
||||
_, _ = w.Write([]byte("xxxx"))
|
||||
}))
|
||||
t.Cleanup(tooBigHeader.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), tooBigHeader.URL, nil); err == nil {
|
||||
t.Fatal("expected content-length guard")
|
||||
}
|
||||
|
||||
chunkedBig := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
// No Content-Length: force LimitReader path to observe oversize body.
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "no hijack", 500)
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
payload := strings.Repeat("x", 16)
|
||||
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n")
|
||||
_, _ = bufrw.WriteString(fmt.Sprintf("%x\r\n%s\r\n0\r\n\r\n", len(payload), payload))
|
||||
_ = bufrw.Flush()
|
||||
}))
|
||||
t.Cleanup(chunkedBig.Close)
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), chunkedBig.URL, nil); err == nil {
|
||||
t.Fatal("expected body size guard")
|
||||
}
|
||||
|
||||
readFail := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
conn, bufrw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = bufrw.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 100\r\n\r\n")
|
||||
_ = bufrw.Flush()
|
||||
_ = conn.Close() // truncate body → ReadAll error
|
||||
}))
|
||||
t.Cleanup(readFail.Close)
|
||||
maxDiffFileSize = 1000
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), readFail.URL, nil); err == nil {
|
||||
t.Fatal("expected read error")
|
||||
}
|
||||
|
||||
if _, err := defaultDiffDownloadLimited(context.Background(), "http://%\x00", nil); err == nil {
|
||||
t.Fatal("expected bad url")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := defaultDiffDownloadLimited(ctx, okSrv.URL, nil); err == nil {
|
||||
t.Fatal("expected canceled ctx")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("computeUnifiedDiff deletes", func(t *testing.T) {
|
||||
text, add, del, hunks, changed := computeUnifiedDiff("a\nb\n", "a\n", 2)
|
||||
if !changed || del < 1 || hunks < 1 || add != 0 || text == "" {
|
||||
t.Fatalf("delete-only diff: changed=%v add=%d del=%d hunks=%d", changed, add, del, hunks)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validation and dry-run modes", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executeMarkdownDiff(t, "diff"); err == nil {
|
||||
t.Fatal("expected missing node")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "0"); err == nil {
|
||||
t.Fatal("expected version>0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "0"); err == nil {
|
||||
t.Fatal("expected version2>0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--context", "-1"); err == nil {
|
||||
t.Fatal("expected context>=0")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", "a.md", "--version2", "2"); err == nil {
|
||||
t.Fatal("expected file/version2 mutex")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1"); err == nil {
|
||||
t.Fatal("expected remote needs version")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2"); err != nil {
|
||||
t.Fatalf("dry-run remote: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--url", "n1", "--file", "local.md"); err != nil {
|
||||
t.Fatalf("dry-run local latest: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "2", "--file", "local.md"); err != nil {
|
||||
t.Fatalf("dry-run local version: %v", err)
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version2", "3"); err != nil {
|
||||
t.Fatalf("dry-run version2-only: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remote vs local and remote vs remote execute", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("alpha\n"), nil
|
||||
}
|
||||
local := filepath.Join(t.TempDir(), "right.md")
|
||||
if err := os.WriteFile(local, []byte("beta\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err != nil {
|
||||
t.Fatalf("local json: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(_ context.Context, url string, _ map[string]string) ([]byte, error) {
|
||||
if strings.Contains(url, "/r") {
|
||||
return []byte("alpha\n"), nil
|
||||
}
|
||||
return []byte("gamma\n"), nil
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
|
||||
t.Fatalf("remote text changed: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "text", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("same\n"), nil
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err != nil {
|
||||
t.Fatalf("remote text unchanged: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"adoc"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1"); err == nil {
|
||||
t.Fatal("expected type guard")
|
||||
}
|
||||
|
||||
testseam.Protect(t, &maxDiffFileSize)
|
||||
maxDiffFileSize = 2
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", local); err == nil {
|
||||
t.Fatal("expected local size fail")
|
||||
}
|
||||
maxDiffFileSize = 10 * 1024 * 1024
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("x\n"), nil
|
||||
}
|
||||
missingLocal := filepath.Join(t.TempDir(), "missing.md")
|
||||
// pass size check by writing then removing after checkFileSize... actually RunE checks size first then reads.
|
||||
// Create file for size check, then make ReadFile fail via directory path.
|
||||
dirAsFile := t.TempDir()
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--file", dirAsFile); err == nil {
|
||||
t.Fatal("expected read local dir failure")
|
||||
}
|
||||
_ = missingLocal
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{err: errors.New("right fail")},
|
||||
}})
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("x\n"), nil
|
||||
}
|
||||
// second download uses version tool — make parse fail on second call via empty resource
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil {
|
||||
t.Fatal("expected right download parse fail")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("json marshal and compute timeout", func(t *testing.T) {
|
||||
testseam.Protect(t, &diffDownloadLimited)
|
||||
testseam.Protect(t, &diffJSONMarshalIndent)
|
||||
testseam.Protect(t, &runMarkdownUnifiedDiff)
|
||||
testseam.Protect(t, &diffComputeTimeout)
|
||||
diffDownloadLimited = func(context.Context, string, map[string]string) ([]byte, error) {
|
||||
return []byte("a\n"), nil
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
diffJSONMarshalIndent = func(any, string, string) ([]byte, error) {
|
||||
return nil, errors.New("marshal boom")
|
||||
}
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "JSON") {
|
||||
t.Fatalf("expected marshal err, got %v", err)
|
||||
}
|
||||
|
||||
diffJSONMarshalIndent = json.MarshalIndent
|
||||
runMarkdownUnifiedDiff = func(string, string, int) (string, int, int, int, bool) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return "", 0, 0, 0, false
|
||||
}
|
||||
diffComputeTimeout = time.Millisecond
|
||||
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"extension":"md"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/l"}`},
|
||||
{text: `{"resourceUrl":"https://example.test/r"}`},
|
||||
}})
|
||||
if err := executeMarkdownDiff(t, "diff", "--node", "n1", "--version", "1", "--version2", "2"); err == nil || !strings.Contains(err.Error(), "超时") {
|
||||
t.Fatalf("expected timeout, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fetchFileInfo extension field", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"extension":".MD","name":"x.md"}`},
|
||||
}})
|
||||
info := fetchFileInfo(context.Background(), "n")
|
||||
if info.extension != "md" || info.name != "x.md" {
|
||||
t.Fatalf("info=%+v", info)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailExportShareAndAtomicWrite(t *testing.T) {
|
||||
t.Run("message export dry-run and execute", func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "named"); err != nil {
|
||||
t.Fatalf("export dry-run: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("export dry-run default name: %v", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"message":{"subject":"Hello/World"}}}`},
|
||||
{text: `{"result":{"emlContent":"From: a\r\n\r\nbody"}}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("export: %v", err)
|
||||
}
|
||||
if _, err := os.Stat("Hello_World.eml"); err != nil {
|
||||
t.Fatalf("missing eml: %v", err)
|
||||
}
|
||||
|
||||
// exist without overwrite
|
||||
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"x"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller2)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World"); err == nil {
|
||||
t.Fatal("expected exist error")
|
||||
}
|
||||
|
||||
caller3 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"y"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller3)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "Hello_World", "--overwrite"); err != nil {
|
||||
t.Fatalf("overwrite: %v", err)
|
||||
}
|
||||
|
||||
// subject fallback to message id when missing
|
||||
caller4 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{}}`},
|
||||
{text: `{"emlContent":"z"}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller4)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "msg-fallback"); err != nil {
|
||||
t.Fatalf("fallback name: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("share-to-chat paths", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err != nil {
|
||||
t.Fatalf("share dry-run: %v", err)
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1"); err != nil {
|
||||
t.Fatalf("share dry-run no users: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
|
||||
// Piped stdin "yes" must not bypass the explicit --yes gate.
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"sign":"sig","riskMessage":"careful"}}`},
|
||||
}})
|
||||
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil {
|
||||
t.Fatal("expected confirmation_required without --yes")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"sign":"sig"}`},
|
||||
}})
|
||||
if err := executeMailShare(t, strings.NewReader("yes\n"),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1"); err == nil {
|
||||
t.Fatal("expected confirmation_required without --yes")
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"sign":"sig","riskMessage":"careful"}`},
|
||||
{text: `{"ok":true}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
|
||||
t.Fatalf("share with yes: %v", err)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"ok":true}`}}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "share-to-chat", "--email", "u@c.com", "--id", "m1", "--users", "u1", "--yes"); err != nil {
|
||||
t.Fatalf("json success path: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("calendar-event missing folder id", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"calendar-event", "list",
|
||||
"--email", "u@c.com",
|
||||
"--start", "2026-07-01T00:00:00Z",
|
||||
"--end", "2026-07-31T23:59:59Z",
|
||||
); err == nil {
|
||||
t.Fatal("expected missing id/folder-id")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sanitize and atomic write", func(t *testing.T) {
|
||||
if got := sanitizeMailFilename(" a/b\\c\x00 "); got != "a_b_c" {
|
||||
t.Fatalf("sanitize=%q", got)
|
||||
}
|
||||
if got := sanitizeMailFilename(" "); got != "mail" {
|
||||
t.Fatalf("empty sanitize=%q", got)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "out.eml")
|
||||
if err := atomicWriteFile(path, []byte("one"), 0o600, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atomicWriteFile(path, []byte("two"), 0o600, false); err == nil {
|
||||
t.Fatal("expected exist without overwrite")
|
||||
}
|
||||
if err := atomicWriteFile(path, []byte("two"), 0o600, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "no-such", "x.eml"), []byte("x"), 0o600, false); err == nil {
|
||||
t.Fatal("expected create temp fail")
|
||||
}
|
||||
|
||||
testseam.Protect(t, &atomicCreateTemp)
|
||||
testseam.Protect(t, &atomicRemove)
|
||||
testseam.Protect(t, &atomicRename)
|
||||
atomicRemove = func(string) error { return nil }
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{chmodErr: errors.New("chmod boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "c.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "权限") {
|
||||
t.Fatalf("chmod: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{writeErr: errors.New("write boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "w.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "写入") {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{syncErr: errors.New("sync boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "s.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "同步") {
|
||||
t.Fatalf("sync: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{closeErr: errors.New("close boom")}, nil
|
||||
}
|
||||
if err := atomicWriteFile(filepath.Join(dir, "cl.eml"), []byte("x"), 0o600, false); err == nil || !strings.Contains(err.Error(), "关闭") {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
atomicCreateTemp = func(string, string) (atomicTempFile, error) {
|
||||
return &atomicFakeTemp{}, nil
|
||||
}
|
||||
atomicRename = func(string, string) error { return errors.New("rename boom") }
|
||||
if err := atomicWriteFile(filepath.Join(dir, "r.eml"), []byte("x"), 0o600, true); err == nil || !strings.Contains(err.Error(), "重命名") {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("export save non-exist failure", func(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
t.Chdir(cwd)
|
||||
testseam.Swap(t, &atomicCreateTemp, func(string, string) (atomicTempFile, error) {
|
||||
return nil, errors.New("nospc")
|
||||
})
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"emlContent":"body"}`},
|
||||
}})
|
||||
if err := executePR868Command(t, newMailCommand(),
|
||||
"message", "export", "--email", "u@c.com", "--id", "m1", "--filename", "x"); err == nil || !strings.Contains(err.Error(), "保存文件失败") {
|
||||
t.Fatalf("expected save failure, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func executeMailShare(t *testing.T, in io.Reader, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = append([]string{"dws", "mail"}, args...)
|
||||
root := newMailCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetIn(in)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDiffEngineEdges(t *testing.T) {
|
||||
// Expand matches backward + negative context clamp + missing newline marker.
|
||||
old := []byte("a\nb\nc\nd\ne")
|
||||
neu := []byte("a\nb\nC\nd\ne\n")
|
||||
diff := UnifiedDiff("o", old, "n", neu, -3)
|
||||
if len(diff) == 0 {
|
||||
t.Fatal("expected diff with negative context")
|
||||
}
|
||||
diff2 := UnifiedDiff("o", []byte("same"), "n", []byte("same\nextra\n"), 2)
|
||||
if !strings.Contains(string(diff2), "No newline at end of file") && !strings.Contains(string(diff2), "+extra") {
|
||||
t.Fatalf("unexpected diff2=%q", diff2)
|
||||
}
|
||||
_ = UnifiedDiff("o", []byte("1\n2\n3\n4\n5\n6\n7\n"), "n", []byte("1\n2\n3\nX\n5\n6\n7\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("only-old\n"), "n", []byte("only-new\n"), 0)
|
||||
|
||||
// Non-unique "common" lines before a unique anchor → backward expand (L90).
|
||||
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\n"), 1)
|
||||
_ = UnifiedDiff("o", []byte("OLD\ncommon\ncommon\nUNIQUE\ntail\n"), "n", []byte("NEW\ncommon\ncommon\nUNIQUE\ntail\nextra\n"), 2)
|
||||
// Large context after an early emitted chunk → chunk.x/y clamps + new-chunk prefix.
|
||||
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("U\nZ\n"), "n", []byte("B\nU\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("A\nU\nZ\n"), "n", []byte("U\nZ\n"), 10)
|
||||
_ = UnifiedDiff("o", []byte("A\n\n\nU\nrest\n"), "n", []byte("B\n\n\nU\nrest\nmore\n"), 5)
|
||||
if nonNeg(-3) != 0 || nonNeg(0) != 0 || nonNeg(4) != 4 {
|
||||
t.Fatalf("nonNeg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveLatestRemaining(t *testing.T) {
|
||||
items := []map[string]any{
|
||||
{"name": "a", "sortTime": int64(5), "rel_path": "same", "fileId": "2", "type": "file"},
|
||||
{"name": "b", "sortTime": int64(5), "rel_path": "same", "fileId": "1", "type": "file"},
|
||||
{"name": "c", "sortTime": int64(5), "rel_path": "z", "fileId": "3", "type": "file"},
|
||||
{"nodeType": "Folder", "name": "folder"},
|
||||
}
|
||||
got := applyDriveListLatest(items, 10)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("len=%d", len(got))
|
||||
}
|
||||
if n, err := json.Number("99").Int64(); err != nil || n != 99 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ms, ok := toMillis(json.Number("99")); !ok || ms != 99 {
|
||||
t.Fatalf("json.Number millis=%v %v", ms, ok)
|
||||
}
|
||||
if _, ok := toMillis(json.Number("-1")); ok {
|
||||
t.Fatal("negative json.Number")
|
||||
}
|
||||
if _, ok := toMillis(struct{}{}); ok {
|
||||
t.Fatal("unknown type")
|
||||
}
|
||||
|
||||
// pagination + quiet=false progress + shortfall hint with pattern
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"x.bin","fileId":"2","type":"file"},{"fileId":"3","type":"file"}],"nextToken":"n1"}`},
|
||||
{text: `{"items":[{"name":"b.md","fileName":"b.md","fileId":"4","type":"file"}],"nextToken":""}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller)
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "drive", "list"}
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.SetContext(context.Background())
|
||||
if err := runDriveListLatest(cmd, map[string]any{"spaceId": "s"}, "folder", 5, "*.md", false); err != nil {
|
||||
t.Fatalf("latest paginate: %v", err)
|
||||
}
|
||||
|
||||
// nil context uses Background
|
||||
caller2 := &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"items":[{"name":"a.md","fileId":"1","type":"file"}],"nextToken":""}`},
|
||||
}}
|
||||
installScriptedCaller(t, caller2)
|
||||
cmd2 := &cobra.Command{Use: "list"}
|
||||
if err := runDriveListLatest(cmd2, nil, "", 1, "", true); err != nil {
|
||||
t.Fatalf("nil ctx: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveListLatestBadFolder(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "list", "--latest", "2", "--folder", "12345"); err == nil {
|
||||
t.Fatal("expected numeric folder rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveDepthLatestTruncatedAndSortTime(t *testing.T) {
|
||||
useDriveDepthArgs(t)
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`{"items":[`)
|
||||
for i := 0; i < driveDepthMaxItems; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&sb, `{"fileId":"f%d","name":"file-%d.txt","type":"FILE","modifiedTime":%d}`, i, i, 1000+i)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: sb.String()}}}
|
||||
out := installDepthCaller(t, caller)
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
err := runDriveListDepth(cmd, newDrivePanDepthRoute(), map[string]any{}, "", 3, "", true, 2)
|
||||
if err == nil || !strings.Contains(err.Error(), "LATEST_SCAN_TRUNCATED") {
|
||||
t.Fatalf("err=%v, want LATEST_SCAN_TRUNCATED", err)
|
||||
}
|
||||
_ = out
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWhiteboardSeams(t *testing.T) {
|
||||
testseam.Swap(t, &whiteboardJSONMarshal, func(any) ([]byte, error) { return nil, errors.New("boom") })
|
||||
if buildWhiteboardCardJSONML("b", "w") != "" {
|
||||
t.Fatal("expected empty on marshal fail")
|
||||
}
|
||||
|
||||
testseam.Swap(t, &prepareWhiteboardCard, func(*cobra.Command, string) (string, error) {
|
||||
return "", errors.New("bad template")
|
||||
})
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err == nil || !strings.Contains(err.Error(), "白板卡片模板") {
|
||||
t.Fatalf("expected prepare fail, got %v", err)
|
||||
}
|
||||
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "doc", "whiteboard"}
|
||||
|
||||
// nil entry in blocks list
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{
|
||||
{text: `{"blocks":[null,{"blockId":"b","jsonml":"[\"card\",{\"metadata\":{\"id\":\"w\"}}]"}]}`},
|
||||
}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err != nil {
|
||||
t.Fatalf("nil entry skip: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executePR868Command(t *testing.T, root *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws", root.Name()}, args...)
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
|
||||
t.Run("hot-word delete dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
|
||||
t.Fatalf("hot-word delete dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("hot-word delete missing words", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete"); err == nil {
|
||||
t.Fatal("expected missing --words error")
|
||||
}
|
||||
})
|
||||
t.Run("hot-word delete executes", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉"); err != nil {
|
||||
t.Fatalf("hot-word delete: %v", err)
|
||||
}
|
||||
if caller.tool != "delete_personal_hotword" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("permission apply dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task-1", "--policy", "4"); err != nil {
|
||||
t.Fatalf("permission apply dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("permission apply alias uuid", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--uuid", "task-2", "--policy", "2"); err != nil {
|
||||
t.Fatalf("permission apply alias: %v", err)
|
||||
}
|
||||
if caller.tool != "apply_minutes_permission" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["taskUuid"] != "task-2" || caller.args["policyId"] != float64(2) {
|
||||
t.Fatalf("args=%#v", caller.args)
|
||||
}
|
||||
})
|
||||
t.Run("permission apply invalid policy", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "1"); err == nil {
|
||||
t.Fatal("expected invalid policy")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task", "--policy", "x"); err == nil {
|
||||
t.Fatal("expected non-numeric policy")
|
||||
}
|
||||
})
|
||||
t.Run("permission apply missing flags", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--policy", "4"); err == nil {
|
||||
t.Fatal("expected missing id")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "permission", "apply", "--id", "task"); err == nil {
|
||||
t.Fatal("expected missing policy")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("audio-memo list default", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list"); err != nil {
|
||||
t.Fatalf("audio-memo list: %v", err)
|
||||
}
|
||||
if caller.tool != "list_audio_memos" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["pageSize"] != float64(200) {
|
||||
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
|
||||
}
|
||||
})
|
||||
t.Run("audio-memo list with range and cursor", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newMinutesCommand(),
|
||||
"audio-memo", "list",
|
||||
"--max", "10",
|
||||
"--cursor", "1740000000000",
|
||||
"--start", "2026-01-01T00:00:00+08:00",
|
||||
"--end", "2026-07-21T23:59:59+08:00",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("audio-memo list ranged: %v", err)
|
||||
}
|
||||
if caller.args["cursor"] != float64(1740000000000) {
|
||||
t.Fatalf("cursor=%#v", caller.args["cursor"])
|
||||
}
|
||||
start, _ := caller.args["startTime"].(string)
|
||||
end, _ := caller.args["endTime"].(string)
|
||||
if !strings.Contains(start, "2026-01-01") || !strings.Contains(end, "2026-07-21") {
|
||||
t.Fatalf("start/end=%q/%q", start, end)
|
||||
}
|
||||
})
|
||||
t.Run("audio-memo list validation", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "0"); err == nil {
|
||||
t.Fatal("expected max validation")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--max", "1001"); err == nil {
|
||||
t.Fatal("expected max upper bound")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--cursor", "-1"); err == nil {
|
||||
t.Fatal("expected cursor validation")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list",
|
||||
"--start", "2026-07-21T00:00:00+08:00", "--end", "2026-01-01T00:00:00+08:00"); err == nil {
|
||||
t.Fatal("expected reversed range error")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--start", "bad"); err == nil {
|
||||
t.Fatal("expected bad start")
|
||||
}
|
||||
if err := executePR868Command(t, newMinutesCommand(), "audio-memo", "list", "--end", "bad"); err == nil {
|
||||
t.Fatal("expected bad end")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocExportGetTaskIDAlias(t *testing.T) {
|
||||
// Existing primary --job-id must remain usable.
|
||||
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"SUCCESS","downloadUrl":"https://x"}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDocCommand(), "export", "get", "--job-id", "job-legacy"); err != nil {
|
||||
t.Fatalf("export get --job-id: %v", err)
|
||||
}
|
||||
if caller.tool != "query_export_job" || caller.args["jobId"] != "job-legacy" {
|
||||
t.Fatalf("tool/args=%q %#v", caller.tool, caller.args)
|
||||
}
|
||||
|
||||
// Add-only synonym --task-id.
|
||||
caller2 := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"status":"PROCESSING"}`}}}
|
||||
installScriptedCaller(t, caller2)
|
||||
if err := executePR868Command(t, newDocCommand(), "export", "get", "--task-id", "job-123"); err != nil {
|
||||
t.Fatalf("export get --task-id: %v", err)
|
||||
}
|
||||
if caller2.args["jobId"] != "job-123" {
|
||||
t.Fatalf("task-id args=%#v", caller2.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
|
||||
t.Run("permission list max-results", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
|
||||
t.Fatalf("permission list: %v", err)
|
||||
}
|
||||
if caller.args["maxResults"] != 10 {
|
||||
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
|
||||
}
|
||||
})
|
||||
t.Run("cover file-id alias", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "cover", "--file-id", "n1"); err != nil {
|
||||
t.Fatalf("cover --file-id: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("revert doc-id alias dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "revert", "--doc-id", "n1", "--version", "3"); err != nil {
|
||||
t.Fatalf("revert --doc-id: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("star add url alias", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newDriveCommand(), "star", "add", "--url", "https://example/n1"); err != nil {
|
||||
t.Fatalf("star add --url: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("download --version routes", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
out := filepath.Join(t.TempDir(), "out.pdf")
|
||||
if err := executePR868Command(t, newDriveCommand(), "download", "--node", "n1", "--version", "3", "--output", out); err != nil {
|
||||
t.Fatalf("download --version: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMailCalendarEventFolderID(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
err := executePR868Command(t, newMailCommand(),
|
||||
"calendar-event", "list",
|
||||
"--email", "a@b.com",
|
||||
"--folder-id", "cal-1",
|
||||
"--start", "2026-07-01T00:00:00Z",
|
||||
"--end", "2026-07-31T23:59:59Z",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calendar-event list --folder-id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnifiedDiffEngine(t *testing.T) {
|
||||
if got := UnifiedDiff("a", []byte("same\n"), "b", []byte("same\n"), 3); got != nil {
|
||||
t.Fatalf("identical should be nil, got %q", got)
|
||||
}
|
||||
old := []byte("one\ntwo\nthree\n")
|
||||
neu := []byte("one\nTWO\nthree\nfour\n")
|
||||
diff := UnifiedDiff("old.txt", old, "new.txt", neu, 2)
|
||||
if len(diff) == 0 || !strings.Contains(string(diff), "@@") {
|
||||
t.Fatalf("expected hunk diff, got %q", diff)
|
||||
}
|
||||
_ = UnifiedDiff("o", []byte("a\nb\n"), "n", []byte("a\nc\n"), 0)
|
||||
_ = UnifiedDiff("o", []byte("{\n\n}\n"), "n", []byte("{\n x\n}\n"), 3)
|
||||
_ = UnifiedDiff("o", []byte("alpha\n"), "n", []byte("beta\n"), 1)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveLatestHelpers(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "list"}
|
||||
cmd.Flags().Int("latest", 0, "")
|
||||
cmd.Flags().String("order-by", "", "")
|
||||
cmd.Flags().String("order", "", "")
|
||||
cmd.Flags().Int("limit", 0, "")
|
||||
cmd.Flags().Int("max", 0, "")
|
||||
cmd.Flags().String("cursor", "", "")
|
||||
cmd.Flags().String("next-token", "", "")
|
||||
|
||||
if err := validateDriveListLatest(cmd, 0); err == nil {
|
||||
t.Fatal("expected latest lower bound")
|
||||
}
|
||||
if err := validateDriveListLatest(cmd, 51); err == nil {
|
||||
t.Fatal("expected latest upper bound")
|
||||
}
|
||||
_ = cmd.ParseFlags([]string{"--order-by=name"})
|
||||
if err := validateDriveListLatest(cmd, 3); err == nil {
|
||||
t.Fatal("expected exclusive order-by")
|
||||
}
|
||||
|
||||
cmd2 := &cobra.Command{Use: "list"}
|
||||
cmd2.Flags().Int("latest", 0, "")
|
||||
cmd2.Flags().String("order-by", "", "")
|
||||
cmd2.Flags().String("order", "", "")
|
||||
cmd2.Flags().Int("limit", 0, "")
|
||||
cmd2.Flags().Int("max", 0, "")
|
||||
cmd2.Flags().String("cursor", "", "")
|
||||
cmd2.Flags().String("next-token", "", "")
|
||||
_ = cmd2.ParseFlags([]string{"--limit=10"})
|
||||
if err := validateDriveListLatest(cmd2, 3); err == nil {
|
||||
t.Fatal("expected exclusive limit")
|
||||
}
|
||||
|
||||
cmd3 := &cobra.Command{Use: "list"}
|
||||
cmd3.Flags().Int("latest", 0, "")
|
||||
cmd3.Flags().String("order-by", "", "")
|
||||
cmd3.Flags().String("order", "", "")
|
||||
cmd3.Flags().Int("limit", 0, "")
|
||||
cmd3.Flags().Int("max", 0, "")
|
||||
cmd3.Flags().String("cursor", "", "")
|
||||
cmd3.Flags().String("next-token", "", "")
|
||||
_ = cmd3.ParseFlags([]string{"--cursor=tok"})
|
||||
if err := validateDriveListLatest(cmd3, 3); err == nil {
|
||||
t.Fatal("expected exclusive cursor")
|
||||
}
|
||||
if err := validateDriveListLatest(cmd3, 3); err == nil {
|
||||
// already failed above
|
||||
}
|
||||
_ = validateDriveListLatest(&cobra.Command{Use: "x"}, 3) // no exclusive flags
|
||||
|
||||
items := []map[string]any{
|
||||
{"name": "b.txt", "sortTime": int64(1), "rel_path": "b", "fileId": "2", "type": "file"},
|
||||
{"name": "a.txt", "sortTime": int64(2), "rel_path": "a", "fileId": "1", "type": "file"},
|
||||
{"name": "dir", "sortTime": int64(9), "type": "folder", "dentryType": "folder"},
|
||||
}
|
||||
got := applyDriveListLatest(items, 1)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("latest len=%d", len(got))
|
||||
}
|
||||
stripDriveDepthDecorations(got)
|
||||
if _, ok := got[0]["sortTime"]; ok {
|
||||
t.Fatal("sortTime should be stripped")
|
||||
}
|
||||
|
||||
if ms, ok := driveItemModifiedMillis(map[string]any{"modifiedTime": float64(123)}); !ok || ms != 123 {
|
||||
t.Fatalf("float millis=%v %v", ms, ok)
|
||||
}
|
||||
if ms, ok := toMillis("2026-01-02T03:04:05Z"); !ok || ms <= 0 {
|
||||
t.Fatalf("rfc3339 millis=%v %v", ms, ok)
|
||||
}
|
||||
if _, ok := toMillis(""); ok {
|
||||
t.Fatal("empty string should fail")
|
||||
}
|
||||
if _, ok := toMillis(float64(-1)); ok {
|
||||
t.Fatal("negative float should fail")
|
||||
}
|
||||
if ms, ok := toMillis("42"); !ok || ms != 42 {
|
||||
t.Fatalf("int string=%v %v", ms, ok)
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
listCmd := &cobra.Command{Use: "list"}
|
||||
listCmd.SetContext(context.Background())
|
||||
if err := runDriveListLatest(listCmd, map[string]any{"spaceId": "s"}, "folder", 2, "*.md", true); err != nil {
|
||||
t.Fatalf("dry-run latest: %v", err)
|
||||
}
|
||||
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{
|
||||
text: `{"items":[{"name":"a.md","fileId":"1","type":"file"},{"name":"skip.bin","fileId":"2","type":"file"},{"name":"dir","type":"FOLDER"}],"nextToken":""}`,
|
||||
}}}
|
||||
installScriptedCaller(t, caller)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "drive", "list"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
listCmd2 := &cobra.Command{Use: "list"}
|
||||
listCmd2.SetContext(context.Background())
|
||||
if err := runDriveListLatest(listCmd2, nil, "", 5, "*.md", false); err != nil {
|
||||
t.Fatalf("latest scan: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func executeWhiteboardCommand(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
oldArgs := os.Args
|
||||
os.Args = append([]string{"dws", "doc", "whiteboard"}, args...)
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
root := newDocWhiteboardCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWhiteboardInsertPaths(t *testing.T) {
|
||||
t.Run("dry-run plan", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1"); err != nil {
|
||||
t.Fatalf("whiteboard insert dry-run: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("insert with placement and verify", func(t *testing.T) {
|
||||
caller := &pr868FlexibleCaller{whiteboardID: "wb-persisted"}
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
|
||||
if err := executeWhiteboardCommand(t,
|
||||
"insert", "--node", "doc-1", "--ref-block", "ref", "--where", "before", "--index", "2", "--yes"); err != nil {
|
||||
t.Fatalf("whiteboard insert: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("soft success when verify empty", func(t *testing.T) {
|
||||
// Empty blocks are eventual-consistency pending, not hard query failure.
|
||||
caller := &pr868FlexibleCaller{emptyVerify: true}
|
||||
testseam.Protect(t, &deps)
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &whiteboardSleep, func(time.Duration) {})
|
||||
if err := executeWhiteboardCommand(t, "insert", "--node", "doc-1", "--yes"); err != nil {
|
||||
t.Fatalf("pending-block soft success: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("helpers", func(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc", "whiteboard", "insert"}
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
|
||||
if buildWhiteboardCardJSONML("b", "w") == "" {
|
||||
t.Fatal("empty jsonml")
|
||||
}
|
||||
if extractWhiteboardID(nil) != "" {
|
||||
t.Fatal("nil attrs")
|
||||
}
|
||||
if extractWhiteboardID(map[string]any{"metadata": map[string]any{"id": "x"}}) != "x" {
|
||||
t.Fatal("extract id")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{err: context.Canceled}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected query error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"other","jsonml":"[]"}]}`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected missing block")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"not-json"}]}`}}})
|
||||
if _, err := queryWhiteboardCardNode(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected jsonml parse error")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"blocks":[{"blockId":"b","jsonml":"[\"card\"]"}]}}`}}})
|
||||
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected missing attrs")
|
||||
}
|
||||
installScriptedCaller(t, &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"blocks":[{"blockId":"b","jsonml":"[\"card\",null]"}]}`}}})
|
||||
if _, err := queryWhiteboardCardAttrs(context.Background(), "n", "b"); err == nil {
|
||||
t.Fatal("expected nil attrs")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type pr868FlexibleCaller struct {
|
||||
whiteboardID string
|
||||
emptyVerify bool
|
||||
format string
|
||||
dry bool
|
||||
}
|
||||
|
||||
func (c *pr868FlexibleCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
if tool == "insert_document_block" {
|
||||
return textToolResult(`{}`), nil
|
||||
}
|
||||
if tool == "list_document_blocks" {
|
||||
if c.emptyVerify {
|
||||
return textToolResult(`{"blocks":[]}`), nil
|
||||
}
|
||||
blockID, _ := args["blockId"].(string)
|
||||
payload := `{"blocks":[{"blockId":"` + blockID + `","jsonml":"[\"card\",{\"metadata\":{\"type\":\"hetu/draw\",\"id\":\"` + c.whiteboardID + `\"}},[\"span\",{},[\"span\",{},\"\"]]]"}]}`
|
||||
return textToolResult(payload), nil
|
||||
}
|
||||
return textToolResult(`{}`), nil
|
||||
}
|
||||
func (c *pr868FlexibleCaller) Format() string { return c.format }
|
||||
func (c *pr868FlexibleCaller) DryRun() bool { return c.dry }
|
||||
func (*pr868FlexibleCaller) Fields() string { return "" }
|
||||
func (*pr868FlexibleCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageMarkdownDiffHelpers(t *testing.T) {
|
||||
if formatFileSize(100) == "" || formatFileSize(2048) == "" || formatFileSize(2*1024*1024) == "" {
|
||||
t.Fatal("formatFileSize")
|
||||
}
|
||||
small := filepath.Join(t.TempDir(), "ok.md")
|
||||
if err := os.WriteFile(small, []byte("hi\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := checkFileSize(small); err != nil {
|
||||
t.Fatalf("checkFileSize small: %v", err)
|
||||
}
|
||||
if err := checkFileSize(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("expected missing file")
|
||||
}
|
||||
|
||||
diff, add, _, hunks, changed := computeUnifiedDiff("a\n", "a\nb\n", 2)
|
||||
if !changed || add < 1 || hunks < 1 || diff == "" {
|
||||
t.Fatalf("computeUnifiedDiff=%v %d %d %q", changed, add, hunks, diff)
|
||||
}
|
||||
if _, _, _, _, ch := computeUnifiedDiff("x\n", "x\n", 2); ch {
|
||||
t.Fatal("identical should be unchanged")
|
||||
}
|
||||
|
||||
right := filepath.Join(t.TempDir(), "right.md")
|
||||
if err := os.WriteFile(right, []byte("hi\nthere\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if describeDingTalkDocType("adoc") == "" {
|
||||
t.Fatal("describeDingTalkDocType")
|
||||
}
|
||||
_ = right
|
||||
}
|
||||
@@ -141,18 +141,23 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
|
||||
previousStdin := os.Stdin
|
||||
previousPut := httpPutFile
|
||||
previousGet := httpGetFile
|
||||
previousWhiteboardSleep := whiteboardSleep
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
os.Stdin = previousStdin
|
||||
httpPutFile = previousPut
|
||||
httpGetFile = previousGet
|
||||
whiteboardSleep = previousWhiteboardSleep
|
||||
})
|
||||
|
||||
caller := &productExampleCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
// Product examples execute real RunE paths; whiteboard insert retries must
|
||||
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
|
||||
whiteboardSleep = func(time.Duration) {}
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
|
||||
if destPath == "" {
|
||||
|
||||
@@ -423,6 +423,8 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
|
||||
range fill / range copy-to / add-dimension / delete-dimension / move-dimension
|
||||
group-dimension / ungroup-dimension
|
||||
set-dropdown / delete-dropdown / csv-put / delete-float-image
|
||||
其中 csv-put 与独立命令语义一致:CSV 字段值以 = 开头时按公式解析,
|
||||
前加单引号(例如 "'=1+1")时写入以 = 开头的字面文本。
|
||||
|
||||
注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立
|
||||
dws sheet group-dimension 命令。
|
||||
@@ -437,7 +439,8 @@ dws sheet group-dimension 命令。
|
||||
dws sheet batch-update --node NODE_ID --operations '[
|
||||
{"toolName":"range clear","input":{"sheet-id":"Sheet1","range":"A1:B3","type":"content"}},
|
||||
{"toolName":"range update","input":{"sheet-id":"Sheet1","range":"A1","values":[[{"type":"text","text":"hello"}]]}},
|
||||
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}}
|
||||
{"toolName":"merge-cells","input":{"sheet-id":"Sheet1","range":"A1:B1","merge-type":"mergeAll"}},
|
||||
{"toolName":"csv-put","input":{"sheet-id":"Sheet1","start-cell":"C1","csv":"=1+1,\u0027=1+1"}}
|
||||
]' --yes
|
||||
|
||||
# 宽松模式
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type csvPutRecordingCaller struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func (c *csvPutRecordingCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.tool = tool
|
||||
c.args = args
|
||||
return &edition.ToolResult{}, nil
|
||||
}
|
||||
|
||||
func (*csvPutRecordingCaller) Format() string { return "json" }
|
||||
func (*csvPutRecordingCaller) DryRun() bool { return false }
|
||||
func (*csvPutRecordingCaller) Fields() string { return "" }
|
||||
func (*csvPutRecordingCaller) JQ() string { return "" }
|
||||
|
||||
func TestCSVPutFormulaContractAndPassThrough(t *testing.T) {
|
||||
const formulaCSV = "=1+1,'=1+1"
|
||||
|
||||
root := &cobra.Command{Use: "sheet"}
|
||||
root.AddCommand(newDataCmds()...)
|
||||
csvPut := findCoverageSubcommand(t, root, "csv-put")
|
||||
for _, want := range []string{"支持公式", "以 = 开头时默认按公式解析", "以 = 开头的字面文本", "'=1+1"} {
|
||||
if !strings.Contains(csvPut.Short+"\n"+csvPut.Long+"\n"+csvPut.Example, want) {
|
||||
t.Fatalf("csv-put help does not contain %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(csvPut.Long, "不支持公式") || strings.Contains(csvPut.Long, "=开头当文本") {
|
||||
t.Fatalf("csv-put help still advertises the old formula contract: %s", csvPut.Long)
|
||||
}
|
||||
if strings.Contains(csvPut.Long, "写入公式文本") {
|
||||
t.Fatalf("csv-put help incorrectly describes apostrophe escaping as formula text: %s", csvPut.Long)
|
||||
}
|
||||
|
||||
caller := &csvPutRecordingCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "sheet", "csv-put"})
|
||||
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{
|
||||
"csv-put",
|
||||
"--node", "node",
|
||||
"--sheet-id", "Sheet1",
|
||||
"--start-cell", "A1",
|
||||
"--csv", formulaCSV,
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute csv-put: %v", err)
|
||||
}
|
||||
if caller.tool != "set_range_from_csv" {
|
||||
t.Fatalf("tool = %q, want set_range_from_csv", caller.tool)
|
||||
}
|
||||
if got := caller.args["csv"]; got != formulaCSV {
|
||||
t.Fatalf("csv argument = %#v, want exact pass-through %q", got, formulaCSV)
|
||||
}
|
||||
if _, ok := caller.args["interpretFormulas"]; ok {
|
||||
t.Fatalf("csv-put unexpectedly added an interpretFormulas argument: %#v", caller.args)
|
||||
}
|
||||
|
||||
batchArgs := BuildCsvPutArgs(map[string]any{
|
||||
"sheet-id": "Sheet1",
|
||||
"start-cell": "A1",
|
||||
"csv": formulaCSV,
|
||||
})
|
||||
if got := batchArgs["csv"]; got != formulaCSV {
|
||||
t.Fatalf("batch csv argument = %#v, want exact pass-through %q", got, formulaCSV)
|
||||
}
|
||||
if _, ok := batchArgs["interpretFormulas"]; ok {
|
||||
t.Fatalf("batch csv-put unexpectedly added an interpretFormulas argument: %#v", batchArgs)
|
||||
}
|
||||
|
||||
batchHelp := newBatchUpdateCmd().Long
|
||||
for _, want := range []string{"csv-put", "以 = 开头时按公式解析", "以 = 开头的字面文本", "'=1+1"} {
|
||||
if !strings.Contains(batchHelp, want) {
|
||||
t.Fatalf("batch-update help does not contain %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(batchHelp, "写入公式文本") {
|
||||
t.Fatalf("batch-update help incorrectly describes apostrophe escaping as formula text: %s", batchHelp)
|
||||
}
|
||||
}
|
||||
@@ -281,16 +281,17 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
|
||||
csvPutCmd := &cobra.Command{
|
||||
Use: "csv-put",
|
||||
Short: "将 CSV 数据写入表格指定位置(纯值,自动扩容)",
|
||||
Short: "将 CSV 数据写入表格指定位置(支持公式,自动扩容)",
|
||||
Long: `将 RFC 4180 格式的 CSV 文本写入指定工作表的指定单元格位置。
|
||||
只写纯值,不支持公式/样式/批注。=开头当文本处理。自动扩容行列。
|
||||
写入值和公式,不支持样式/批注。字段值以 = 开头时默认按公式解析;
|
||||
如需写入以 = 开头的字面文本,在字段值前加单引号(例如 "'=1+1")。自动扩容行列。
|
||||
目标区域如含合并单元格,csv-put 会打散合并并覆盖所有单元格;这不同于
|
||||
range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
|
||||
|
||||
--csv 支持三种输入:直接传文本、@filepath 从文件读取、- 从 stdin 读取。
|
||||
--allow-overwrite 默认 false,目标区域有数据时需显式传 --allow-overwrite 才能覆盖。`,
|
||||
Example: ` dws sheet csv-put --node NODE_ID --sheet-id SHEET_ID --start-cell A1 \
|
||||
--csv 'name,score\nalice,95\nbob,87'
|
||||
--csv "=1+1,'=1+1"
|
||||
|
||||
dws sheet csv-put --node NODE_ID --sheet-id SHEET_ID --start-cell B2 \
|
||||
--csv @data.csv --allow-overwrite
|
||||
@@ -347,17 +348,20 @@ range update 与合并区域冲突时返回 MERGED_CELLS_CONFLICT 的行为。
|
||||
CLIPath: "sheet csv-put",
|
||||
PrimaryCLIPath: "sheet csv-put",
|
||||
},
|
||||
Description: "将 CSV 纯值写入起始单元格(可自动扩容;大批量纯值首选)。",
|
||||
Description: "将 CSV 值或公式写入起始单元格(= 开头按公式;可自动扩容)。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "set_range_from_csv"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将 CSV 纯值写入起始单元格(可自动扩容;大批量纯值首选)。",
|
||||
UseWhen: []string{"写入纯值且超过约 5 行/20 格,或数据来自 CSV/表格文本时优先使用"},
|
||||
AvoidWhen: []string{"需要公式/超链接/富文本用 range update;在末尾追加数据行用 append;覆盖已有数据需 --allow-overwrite"},
|
||||
Examples: []string{"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv @data.csv --allow-overwrite"},
|
||||
AgentSummary: "将 CSV 值或公式写入起始单元格(= 开头按公式;可自动扩容)。",
|
||||
UseWhen: []string{"需要从 CSV/表格文本批量写入值或以 = 开头的公式时优先使用"},
|
||||
AvoidWhen: []string{"需要超链接/富文本/样式/数据验证用 range update;在末尾追加数据行用 append;覆盖已有数据需 --allow-overwrite;要把 = 开头内容写成字面文本需前加单引号"},
|
||||
Examples: []string{
|
||||
"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv @data.csv --allow-overwrite",
|
||||
"dws sheet csv-put --node <NODE_ID> --sheet-id <SHEET_ID> --start-cell A1 --csv \"=1+1,'=1+1\"",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "nodeId"},
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const sheetFormulaVerifyRemoteTool = "verify_formula"
|
||||
|
||||
func newSheetFormulaVerifyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "formula-verify",
|
||||
@@ -95,9 +97,9 @@ func newSheetFormulaVerifyCmd() *cobra.Command {
|
||||
// 返回 payload 判定是否存在公式错误,故走 ReturnText 再自行 PrintJSON。
|
||||
func callMCPToolFormulaVerify(toolArgs map[string]any, exitOnError bool) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("sheet", "formula_verify", toolArgs)
|
||||
return callMCPToolOnServer("sheet", sheetFormulaVerifyRemoteTool, toolArgs)
|
||||
}
|
||||
text, err := callMCPToolReturnTextOnServer(context.Background(), "sheet", "formula_verify", toolArgs)
|
||||
text, err := callMCPToolReturnTextOnServer(context.Background(), "sheet", sheetFormulaVerifyRemoteTool, toolArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -110,7 +112,7 @@ func callMCPToolFormulaVerify(toolArgs map[string]any, exitOnError bool) error {
|
||||
return nil
|
||||
}
|
||||
if parsed == nil {
|
||||
return fmt.Errorf("formula_verify returned empty result")
|
||||
return fmt.Errorf("%s returned empty result", sheetFormulaVerifyRemoteTool)
|
||||
}
|
||||
if err := deps.Out.PrintJSON(parsed); err != nil {
|
||||
return err
|
||||
|
||||
@@ -42,6 +42,12 @@ func TestCrossPlatformCoverageSheetFormulaVerifyLimitsAndInlineTargets(t *testin
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", caller.calls)
|
||||
}
|
||||
if caller.server != "sheet" {
|
||||
t.Fatalf("server = %q, want sheet", caller.server)
|
||||
}
|
||||
if caller.tool != "verify_formula" {
|
||||
t.Fatalf("tool = %q, want verify_formula", caller.tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetFormulaVerifyTargetsFromFile(t *testing.T) {
|
||||
|
||||
@@ -168,7 +168,7 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
|
||||
- 写图片到单元格建议直接用 dws sheet write-image(更简洁)
|
||||
- 只设样式或批量刷整片区域样式请用 dws sheet range set-style;写值同时设置少量 cell 样式可用 cellStyles
|
||||
- 目标范围与已有合并区域冲突时,range update 会返回 MERGED_CELLS_CONFLICT;先用 sheet info 查看 mergedRanges,取消合并后写入,必要时再重新合并
|
||||
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入纯值
|
||||
- csv-put 的合并处理不同:目标区域含合并单元格时会打散合并并写入 CSV 值或公式
|
||||
- 清空整片区域请用 dws sheet range clear`,
|
||||
Example: ` # 写入文本
|
||||
dws sheet range update --node NODE_ID --sheet-id SHEET_ID --range "A1:B2" \
|
||||
@@ -252,16 +252,16 @@ dws sheet info --node NODE_ID --sheet-id SHEET_ID --format json,并读取 merg
|
||||
CLIPath: "sheet range update",
|
||||
PrimaryCLIPath: "sheet range update",
|
||||
},
|
||||
Description: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或含公式时使用。",
|
||||
Description: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或需富格式时使用。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: "update_range"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或含公式时使用。",
|
||||
UseWhen: []string{"需要写入少量单元格、公式或超链接,且能提供 sheetId 与精确 range 时"},
|
||||
AvoidWhen: []string{"大批量纯值写入优先 sheet csv-put;末尾追加行用 append;全局替换文本用 replace;只改样式用 range set-style;清整片区域用 range clear"},
|
||||
AgentSummary: "更新指定区域单元格(值/公式/超链接等 object 协议);少量或需富格式时使用。",
|
||||
UseWhen: []string{"需要写入少量单元格、公式对象、超链接或富格式,且能提供 sheetId 与精确 range 时"},
|
||||
AvoidWhen: []string{"CSV 形式的大批量值或公式写入优先 sheet csv-put;末尾追加行用 append;全局替换文本用 replace;只改样式用 range set-style;清整片区域用 range clear"},
|
||||
Examples: []string{"dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range \"A1\" --values '[[{\"type\":\"text\",\"text\":\"张三\"}]]'"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
|
||||
@@ -1,63 +1,96 @@
|
||||
{
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
|
||||
" 组织主管理员:": " Organization super admins: ",
|
||||
" 请检查网络连接后重试。": " Please check your network connection and retry.",
|
||||
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
|
||||
" (每 %d 秒轮询一次)": " (polling every %d seconds)",
|
||||
" 授权码: %s": " authorization code: %s",
|
||||
" 链接: %s": " link: %s",
|
||||
"%s (第 %d 次尝试)\\\n": "%s (attempt %d)\\\n",
|
||||
"--title 的别名": "Alias for --title",
|
||||
"AI 表格 Base": "AI table Base",
|
||||
"AI 表格 Base 管理": "AI table Base management",
|
||||
"AITable 多维表格管理": "AITable multi-dimensional table management",
|
||||
"AI诚聘": "AI Recruitment",
|
||||
"Base ID (必填)": "Base ID (required)",
|
||||
"DNS 解析失败。请检查域名拼写和网络 DNS 配置。": "DNS resolution failed. Check the domain spelling and network DNS settings.",
|
||||
"HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。": "HTTP request timed out waiting for the server. Increase --timeout or check the server.",
|
||||
"MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP service returned an unparseable protocol response; check service version or upstream proxy.",
|
||||
"OSS 上传失败 HTTP %d: %s": "OSS upload failed HTTP %d: %s",
|
||||
"TLS 握手超时。请检查网络连接或代理设置。": "TLS handshake timed out. Check the network connection or proxy settings.",
|
||||
"[%d] 轮询中... (%ds)": "[%d] polling ... (%ds)",
|
||||
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload returned abnormal format",
|
||||
"refresh_token 刷新失败": "refresh_token refresh failed",
|
||||
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token refresh failed, will attempt QR code login",
|
||||
"true=已完成, false=未完成": "true=completed, false=not completed",
|
||||
"⏳ 等待授权中...": "⏳ Waiting for authorization...",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
|
||||
"⚠️ 即将删除 %s: %s\\\n": "⚠️ About to delete %s: %s\\\n",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ Unable to verify CLI data access permission status",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ CLI data access is not enabled for this organization",
|
||||
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
|
||||
"上传失败: %w": "upload failed: %w",
|
||||
"上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。": "Upstream service error; retry later. If persistent, check recovery snapshot.",
|
||||
"上游服务异常;可稍后重试。": "Upstream service error; retry later.",
|
||||
"下载失败 (HTTP %d): %s": "download failed (HTTP %d): %s",
|
||||
"下载失败: %w": "Download failed: %w",
|
||||
"不是文件: ": "not a file: ",
|
||||
"人才查询": "Talent query",
|
||||
"优先级: 10低/20普通/30较高/40紧急": "Priority: 10=low/20=normal/30=high/40=urgent",
|
||||
"使用授权码换取 Access Token...": "Exchanging authorization code for Access Token...",
|
||||
"保存 token 失败": "Failed to save token",
|
||||
"保存Keyword subscription规则": "Save keyword subscription rule",
|
||||
"修改待办任务": "Update todo task",
|
||||
"修改执行者的待办完成状态": "Update executor todo done status",
|
||||
"写入文件失败: %w": "write file failed: %w",
|
||||
"准备上传失败: %w": "prepare upload failed: %w",
|
||||
"分页查询审批实例": "Paginated query approval instances",
|
||||
"列出已安装的插件": "List installed plugins",
|
||||
"列出插件所有配置项": "List all config values for a plugin",
|
||||
"创建 / 查询 / 更新 / 删除待办": "Create / query / update / delete todo",
|
||||
"创建待办": "Create todo",
|
||||
"创建文件失败: %w": "create file failed: %w",
|
||||
"创建请求失败": "Failed to create request",
|
||||
"删除待办": "Delete todo",
|
||||
"删除 AI 表格": "Delete AI table",
|
||||
"删除原因(可选)": "Delete reason (optional)",
|
||||
"删除字段": "Delete field",
|
||||
"删除待办": "Delete todo",
|
||||
"删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified Base (high risk, irreversible). Use --yes to skip confirmation.",
|
||||
"删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified field (high risk, irreversible). Use --yes to skip confirmation.",
|
||||
"删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "Delete specified data table (high risk, irreversible). Use --yes to skip confirmation.",
|
||||
"删除插件配置项": "Remove a plugin config value",
|
||||
"删除数据表": "Delete data table",
|
||||
"删除行记录": "Delete row records",
|
||||
"卸载已安装的插件": "Remove an installed plugin",
|
||||
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "Parameters do not match tool input schema; check --json/--params/flags.",
|
||||
"发送请求失败": "Failed to send request",
|
||||
"启用插件": "Enable a plugin",
|
||||
"回调中未收到授权码": "Callback did not receive authorization code",
|
||||
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "If the browser did not open automatically, please visit:\n %s\n\n",
|
||||
"字段": "field",
|
||||
"字段 ID (必填)": "field ID (required)",
|
||||
"字段管理": "Field management",
|
||||
"安装插件": "Install a plugin",
|
||||
"完成状态: true/false": "Done status: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
|
||||
"审批实例管理": "Approval instance management",
|
||||
"将插件 stdio server 编译为原生二进制": "Build plugin's stdio server into a native binary",
|
||||
"将本地目录注册为开发态插件": "Register a local directory as a dev plugin",
|
||||
"岗位查询": "Position query",
|
||||
"工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。": "Tool protocol incompatible; check service version, tool name, or upgrade to a dws version with the latest static endpoints.",
|
||||
"工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.",
|
||||
"已取消操作": "Operation cancelled",
|
||||
"当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s",
|
||||
"待办": "todo",
|
||||
"待办任务 ID (必填)": "Todo task ID (required)",
|
||||
"待办任务管理": "Todo task management",
|
||||
"待办标题 (必填)": "Todo title (required)",
|
||||
"待办详情": "Todo detail",
|
||||
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "Recurring todo (requires --due); format: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"或者直接打开以下链接:": "Or open the following link:",
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)",
|
||||
"所有凭证已失效,请运行 dws auth login 重新登录": "All credentials have expired, please run dws auth login to re-authenticate",
|
||||
"打开 zip 失败: %w": "open zip failed: %w",
|
||||
"执行者 userId 列表 (必填)": "Executor userId list (required)",
|
||||
"批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。": "Batch delete records (high risk, irreversible), max 100 per batch. Use --yes to skip confirmation.",
|
||||
"换取 token 失败": "Failed to exchange token",
|
||||
"授权失败:未收到授权码": "Authorization failed: no authorization code received",
|
||||
@@ -65,12 +98,15 @@
|
||||
"授权码将在 %d 秒后过期。": "Authorization code will expire in %d seconds.",
|
||||
"授权码已过期,正在重新发起设备授权流程...": "Authorization code expired, restarting device authorization flow...",
|
||||
"授权超时(5分钟),请重试": "Authorization timeout (5 minutes), please retry",
|
||||
"插件管理": "Manage plugins",
|
||||
"操作超时,请重新登录": "Operation timed out, please re-login",
|
||||
"数据表": "data table",
|
||||
"数据表 ID (必填)": "data table ID (required)",
|
||||
"数据表管理": "Data table management",
|
||||
"文件不存在: ": "File not found: ",
|
||||
"文件为空": "File is empty",
|
||||
"文件过大 (%d 字节,限制 %d 字节)": "File too large (%d bytes, limit %d bytes)",
|
||||
"新标题": "New title",
|
||||
"无法打开文件: %w": "Cannot open file: %w",
|
||||
"无法自动打开浏览器": "Cannot automatically open browser",
|
||||
"无法获取当前二进制路径: %w": "Cannot get current binary path: %w",
|
||||
@@ -78,10 +114,10 @@
|
||||
"无法解析符号链接: %w": "Cannot resolve symlink: %w",
|
||||
"无法读取当前二进制信息: %w": "Cannot read current binary info: %w",
|
||||
"无法连接到更新服务器: %w": "Cannot connect to update server: %w",
|
||||
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "Static endpoint/negotiation failed; check network, service version, or upgrade to a dws version with the latest static endpoints.",
|
||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "Help provides help for any command in the application.\nSimply type dws help [path to command] for full details.",
|
||||
"服务端返回了空的 device_code 或 user_code": "Server returned empty device_code or user_code",
|
||||
"服务端返回错误": "Server returned error",
|
||||
"服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。": "Service returned empty results; retry later. Check recovery snapshot if persistent.",
|
||||
"服务返回了空结果;请稍后重试。": "Service returned empty results; please retry later.",
|
||||
"未找到 MCP Server URL": "MCP Server URL not found",
|
||||
"未找到认证信息,请运行 dws auth login": "No credentials found, please run dws auth login",
|
||||
"未登录,请运行 dws auth login": "Not logged in, please run dws auth login",
|
||||
@@ -90,42 +126,43 @@
|
||||
"本地文件路径 (必填)": "Local file path (required)",
|
||||
"权限不足;请检查当前身份是否有权限访问该服务或工具。": "Insufficient permissions; check if current identity has access to this service or tool.",
|
||||
"构建上传请求失败: %w": "Failed to build upload request: %w",
|
||||
"查看任意命令的帮助信息": "Help about any command",
|
||||
"查看插件详情": "Show plugin details",
|
||||
"查询入职人才": "Query onboarding candidates",
|
||||
"查询在招岗位": "Query open positions",
|
||||
"修改待办任务": "Update todo task",
|
||||
"修改执行者的待办完成状态": "Update executor todo done status",
|
||||
"优先级: 10低/20普通/30较高/40紧急": "Priority: 10=low/20=normal/30=high/40=urgent",
|
||||
"查询待办列表": "List todos",
|
||||
"待办": "todo",
|
||||
"待办任务 ID (必填)": "Todo task ID (required)",
|
||||
"待办任务管理": "Todo task management",
|
||||
"待办标题 (必填)": "Todo title (required)",
|
||||
"待办详情": "Todo detail",
|
||||
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "Recurring todo (requires --due); format: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "Due time ISO-8601 (e.g. 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "Executor userId list (required)",
|
||||
"检查登录状态后重试": "Check login status and retry",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command",
|
||||
"校验 plugin.json": "Validate a plugin.json",
|
||||
"检查 CLI 授权状态失败": "Failed to check CLI auth status",
|
||||
"检查 internal/syncdata 静态端点生成物是否包含目标 server": "Check whether internal/syncdata static endpoint artifacts include the target server",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "Check service connectivity and retry; if persistent, verify MCP service is responding.",
|
||||
"检查服务连通性和协议版本后重试": "Check service connectivity and protocol version, then retry",
|
||||
"检查登录状态后重试": "Check login status and retry",
|
||||
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "Check network, proxy, and DNS settings, then retry the original command",
|
||||
"检查认证、权限和参数后重试原命令": "Check authentication, permissions, and parameters, then retry",
|
||||
"新标题": "New title",
|
||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "Manage DingTalk personal todos: create, list, view detail, update, mark done, delete.",
|
||||
"步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "Step 1/3: Preparing upload %s (%d bytes, %s)...\\\n",
|
||||
"步骤 2/3: 上传文件到 OSS...": "Step 2/3: Uploading file to OSS...",
|
||||
"步骤 3/3: 上传完成!": "Step 3/3: Upload complete!",
|
||||
"用户拒绝了授权请求": "User rejected the authorization request",
|
||||
"完成状态: true/false": "Done status: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "Done status: true=completed, false=not completed (required)",
|
||||
"确认删除? (yes/no): ": "Confirm delete? (yes/no): ",
|
||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "Verify the MCP service is reachable; if the failure persists, retry later",
|
||||
"确认删除? (yes/no): ": "Confirm delete? (yes/no): ",
|
||||
"禁用插件": "Disable a plugin",
|
||||
"等待提交申请中": "Waiting to submit request",
|
||||
"等待用户授权...": "Waiting for user authorization...",
|
||||
"等待管理员审批中": "Waiting for admin approval",
|
||||
"管理员操作入口:": "Admin settings: ",
|
||||
"管理插件配置": "Manage plugin configuration",
|
||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "Manage DingTalk personal todos: create, list, view detail, update, mark done, delete.",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "Result format does not match client expectations; check service protocol changes or rollback to latest working version.",
|
||||
"网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。": "Network I/O timed out. Increase --timeout or check the network.",
|
||||
"网络错误,继续重试...": "Network error, retrying...",
|
||||
"脚手架生成新插件目录": "Scaffold a new plugin directory",
|
||||
"获取使用指引": "Get usage guide",
|
||||
"获取审批实例详情": "Get approval instance details",
|
||||
"获取数量,超过 20 自动分页 (默认 20)": "Fetch count, auto-paginate if over 20 (default 20)",
|
||||
"获取版本信息失败 (HTTP %d)": "Get version info failed (HTTP %d)",
|
||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "Override OAuth client ID (DingTalk AppKey)",
|
||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "Override OAuth client secret (DingTalk AppSecret)",
|
||||
"解析令牌数据失败": "Failed to parse token data",
|
||||
"解析响应失败": "Failed to parse response",
|
||||
"解析版本信息失败: %w": "Parse version info failed: %w",
|
||||
@@ -138,59 +175,32 @@
|
||||
"设备授权流程失败(已重试 %d 次)": "Device authorization flow failed (retried %d times)",
|
||||
"设备授权码已过期": "Device authorization code has expired",
|
||||
"设备授权码已过期(%d 秒),请重试": "Device authorization code expired (%d seconds), please retry",
|
||||
"设置插件配置项": "Set a plugin config value",
|
||||
"设置权限失败: %w": "Set permission failed: %w",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
|
||||
"请在浏览器中完成扫码授权。": "Please complete QR code authorization in the browser.",
|
||||
"请在浏览器中打开以下链接,并输入授权码:": "Please open the following link in your browser and enter the authorization code:",
|
||||
"请检查服务 endpoint 是否为空或格式不合法。": "Please check if the service endpoint is empty or has invalid format.",
|
||||
"请检查网络连通性和 MCP 服务状态后重试。": "Check network connectivity and MCP service status, then retry.",
|
||||
"请求在重试过程中被取消;请检查调用侧超时设置。": "Request was cancelled during retry; check caller-side timeout settings.",
|
||||
"请求已取消。如果非手动取消,请检查调用侧超时设置。": "Request cancelled. If not cancelled manually, check the caller timeout settings.",
|
||||
"请求被上游服务拒绝;请检查参数、认证和权限配置。": "Request was rejected by upstream service; check parameters, authentication, and permissions.",
|
||||
"请求设备授权码...": "Requesting device authorization code...",
|
||||
"请求设备授权码失败": "Failed to request device authorization code",
|
||||
"请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。": "Request timed out (context deadline exceeded). Increase --timeout or check the network.",
|
||||
"读取 zip 条目失败: %w": "Failed to read zip entry: %w",
|
||||
"读取响应失败": "Failed to read response",
|
||||
"读取版本信息失败: %w": "Read version info failed: %w",
|
||||
"跳过确认直接删除": "Skip confirmation and delete directly",
|
||||
"--title 的别名": "Alias for --title",
|
||||
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "Call rejected; check authentication status, tenant identity, or access permissions.",
|
||||
"轮询过快,间隔增加至 %ds": "Polling too fast, interval increased to %ds",
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken",
|
||||
"附件工作流": "Attachment workflow",
|
||||
"页码 (必填)": "page number (required)",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ Unable to verify CLI data access permission status",
|
||||
" 请检查网络连接后重试。": " Please check your network connection and retry.",
|
||||
"检查 CLI 授权状态失败": "Failed to check CLI auth status",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ CLI data access is not enabled for this organization",
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " The organization admin has not enabled \"Allow members to access their personal data via CLI\".",
|
||||
" 组织主管理员:": " Organization super admins: ",
|
||||
" 请联系组织主管理员开启后重新登录。": " Please contact the organization super admin to enable it and re-login.",
|
||||
"管理员操作入口:": "Admin settings: ",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "CLI data access is not enabled for this organization, please contact admin to enable it",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ CLI data access is not enabled for this organization, please submit an authorization request in the browser...",
|
||||
"✅ 权限已开启,继续登录...": "✅ Permission enabled, continuing login...",
|
||||
"等待管理员审批中": "Waiting for admin approval",
|
||||
"等待提交申请中": "Waiting to submit request",
|
||||
"操作超时,请重新登录": "Operation timed out, please re-login",
|
||||
"检查组织 CLI 授权状态...": "Checking organization CLI auth status...",
|
||||
"🔐 登录钉钉": "🔐 Login to DingTalk",
|
||||
"插件管理": "Manage plugins",
|
||||
"列出已安装的插件": "List installed plugins",
|
||||
"安装插件": "Install a plugin",
|
||||
"查看插件详情": "Show plugin details",
|
||||
"启用插件": "Enable a plugin",
|
||||
"禁用插件": "Disable a plugin",
|
||||
"卸载已安装的插件": "Remove an installed plugin",
|
||||
"校验 plugin.json": "Validate a plugin.json",
|
||||
"脚手架生成新插件目录": "Scaffold a new plugin directory",
|
||||
"将本地目录注册为开发态插件": "Register a local directory as a dev plugin",
|
||||
"管理插件配置": "Manage plugin configuration",
|
||||
"设置插件配置项": "Set a plugin config value",
|
||||
"读取插件配置项": "Get a plugin config value",
|
||||
"列出插件所有配置项": "List all config values for a plugin",
|
||||
"删除插件配置项": "Remove a plugin config value",
|
||||
"将插件 stdio server 编译为原生二进制": "Build plugin's stdio server into a native binary",
|
||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "Override OAuth client ID (DingTalk AppKey)",
|
||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "Override OAuth client secret (DingTalk AppSecret)",
|
||||
"查看任意命令的帮助信息": "Help about any command",
|
||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "Help provides help for any command in the application.\nSimply type dws help [path to command] for full details."
|
||||
"读取版本信息失败: %w": "Read version info failed: %w",
|
||||
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "Call rejected; check authentication status, tenant identity, or access permissions.",
|
||||
"跳过确认直接删除": "Skip confirmation and delete directly",
|
||||
"轮询过快,间隔增加至 %ds": "Polling too fast, interval increased to %ds",
|
||||
"运行 dws auth status 确认凭证有效,必要时重新登录": "Run dws auth status to confirm credentials are valid; re-login if needed",
|
||||
"运行 sync-oss 重新生成静态端点与路由后重试": "Run sync-oss to regenerate static endpoints and routes, then retry",
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "Response data missing uploadUrl or fileToken",
|
||||
"连接被拒绝。请确认服务端已启动并正在监听。": "Connection refused. Confirm the server is running and listening.",
|
||||
"附件工作流": "Attachment workflow",
|
||||
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "Static endpoint/negotiation failed; check network, service version, or upgrade to a dws version with the latest static endpoints.",
|
||||
"页码 (必填)": "page number (required)",
|
||||
"🔐 登录钉钉": "🔐 Login to DingTalk"
|
||||
}
|
||||
|
||||
@@ -1,63 +1,96 @@
|
||||
{
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
|
||||
" 组织主管理员:": " 组织主管理员:",
|
||||
" 请检查网络连接后重试。": " 请检查网络连接后重试。",
|
||||
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
|
||||
" (每 %d 秒轮询一次)": " (每 %d 秒轮询一次)",
|
||||
" 授权码: %s": " 授权码: %s",
|
||||
" 链接: %s": " 链接: %s",
|
||||
"%s (第 %d 次尝试)\\\n": "%s (第 %d 次尝试)\\\n",
|
||||
"--title 的别名": "--title 的别名",
|
||||
"AI 表格 Base": "AI 表格 Base",
|
||||
"AI 表格 Base 管理": "AI 表格 Base 管理",
|
||||
"AITable 多维表格管理": "AITable 多维表格管理",
|
||||
"AI诚聘": "AI诚聘",
|
||||
"Base ID (必填)": "Base ID (必填)",
|
||||
"DNS 解析失败。请检查域名拼写和网络 DNS 配置。": "DNS 解析失败。请检查域名拼写和网络 DNS 配置。",
|
||||
"HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。": "HTTP 请求超时(等待服务端响应超时)。可通过 --timeout 增大超时时间,或检查服务端是否正常。",
|
||||
"MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。": "MCP 服务返回了无法解析的协议响应;检查服务版本或上游代理。",
|
||||
"OSS 上传失败 HTTP %d: %s": "OSS 上传失败 HTTP %d: %s",
|
||||
"TLS 握手超时。请检查网络连接或代理设置。": "TLS 握手超时。请检查网络连接或代理设置。",
|
||||
"[%d] 轮询中... (%ds)": "[%d] 轮询中... (%ds)",
|
||||
"prepare_attachment_upload 返回格式异常": "prepare_attachment_upload 返回格式异常",
|
||||
"refresh_token 刷新失败": "refresh_token 刷新失败",
|
||||
"refresh_token 刷新失败,将尝试扫码登录": "refresh_token 刷新失败,将尝试扫码登录",
|
||||
"true=已完成, false=未完成": "true=已完成, false=未完成",
|
||||
"⏳ 等待授权中...": "⏳ 等待授权中...",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
|
||||
"⚠️ 即将删除 %s: %s\\\n": "⚠️ 即将删除 %s: %s\\\n",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限",
|
||||
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
|
||||
"上传失败: %w": "上传失败: %w",
|
||||
"上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。": "上游服务异常;可稍后重试,若持续失败请查看 recovery snapshot。",
|
||||
"上游服务异常;可稍后重试。": "上游服务异常;可稍后重试。",
|
||||
"下载失败 (HTTP %d): %s": "下载失败 (HTTP %d): %s",
|
||||
"下载失败: %w": "下载失败: %w",
|
||||
"不是文件: ": "不是文件: ",
|
||||
"人才查询": "人才查询",
|
||||
"优先级: 10低/20普通/30较高/40紧急": "优先级: 10低/20普通/30较高/40紧急",
|
||||
"使用授权码换取 Access Token...": "使用授权码换取 Access Token...",
|
||||
"保存 token 失败": "保存 token 失败",
|
||||
"保存Keyword subscription规则": "保存Keyword subscription规则",
|
||||
"修改待办任务": "修改待办任务",
|
||||
"修改执行者的待办完成状态": "修改执行者的待办完成状态",
|
||||
"写入文件失败: %w": "写入文件失败: %w",
|
||||
"准备上传失败: %w": "准备上传失败: %w",
|
||||
"分页查询审批实例": "分页查询审批实例",
|
||||
"列出已安装的插件": "列出已安装的插件",
|
||||
"列出插件所有配置项": "列出插件所有配置项",
|
||||
"创建 / 查询 / 更新 / 删除待办": "创建 / 查询 / 更新 / 删除待办",
|
||||
"创建待办": "创建待办",
|
||||
"创建文件失败: %w": "创建文件失败: %w",
|
||||
"创建请求失败": "创建请求失败",
|
||||
"删除待办": "删除待办",
|
||||
"删除 AI 表格": "删除 AI 表格",
|
||||
"删除原因(可选)": "删除原因(可选)",
|
||||
"删除字段": "删除字段",
|
||||
"删除待办": "删除待办",
|
||||
"删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定 Base(高风险、不可逆)。使用 --yes 跳过确认。",
|
||||
"删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定字段(高风险、不可逆)。使用 --yes 跳过确认。",
|
||||
"删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。": "删除指定数据表(高风险、不可逆)。使用 --yes 跳过确认。",
|
||||
"删除插件配置项": "删除插件配置项",
|
||||
"删除数据表": "删除数据表",
|
||||
"删除行记录": "删除行记录",
|
||||
"卸载已安装的插件": "卸载已安装的插件",
|
||||
"参数不符合工具输入 schema;请检查 --json/--params/flags。": "参数不符合工具输入 schema;请检查 --json/--params/flags。",
|
||||
"发送请求失败": "发送请求失败",
|
||||
"启用插件": "启用插件",
|
||||
"回调中未收到授权码": "回调中未收到授权码",
|
||||
"如果浏览器未自动打开,请手动访问:\n %s\n\n": "如果浏览器未自动打开,请手动访问:\n %s\n\n",
|
||||
"字段": "字段",
|
||||
"字段 ID (必填)": "字段 ID (必填)",
|
||||
"字段管理": "字段管理",
|
||||
"安装插件": "安装插件",
|
||||
"完成状态: true/false": "完成状态: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
|
||||
"审批实例管理": "审批实例管理",
|
||||
"将插件 stdio server 编译为原生二进制": "将插件 stdio server 编译为原生二进制",
|
||||
"将本地目录注册为开发态插件": "将本地目录注册为开发态插件",
|
||||
"岗位查询": "岗位查询",
|
||||
"工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。": "工具协议不兼容;请检查服务版本、工具名或升级到包含最新静态端点的 dws 版本。",
|
||||
"工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。",
|
||||
"已取消操作": "已取消操作",
|
||||
"当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制",
|
||||
"待办": "待办",
|
||||
"待办任务 ID (必填)": "待办任务 ID (必填)",
|
||||
"待办任务管理": "待办任务管理",
|
||||
"待办标题 (必填)": "待办标题 (必填)",
|
||||
"待办详情": "待办详情",
|
||||
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"或者直接打开以下链接:": "或者直接打开以下链接:",
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)",
|
||||
"所有凭证已失效,请运行 dws auth login 重新登录": "所有凭证已失效,请运行 dws auth login 重新登录",
|
||||
"打开 zip 失败: %w": "打开 zip 失败: %w",
|
||||
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
|
||||
"批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。": "批量删除记录(高风险、不可逆),单次最多 100 条。使用 --yes 跳过确认。",
|
||||
"换取 token 失败": "换取 token 失败",
|
||||
"授权失败:未收到授权码": "授权失败:未收到授权码",
|
||||
@@ -65,12 +98,15 @@
|
||||
"授权码将在 %d 秒后过期。": "授权码将在 %d 秒后过期。",
|
||||
"授权码已过期,正在重新发起设备授权流程...": "授权码已过期,正在重新发起设备授权流程...",
|
||||
"授权超时(5分钟),请重试": "授权超时(5分钟),请重试",
|
||||
"插件管理": "插件管理",
|
||||
"操作超时,请重新登录": "操作超时,请重新登录",
|
||||
"数据表": "数据表",
|
||||
"数据表 ID (必填)": "数据表 ID (必填)",
|
||||
"数据表管理": "数据表管理",
|
||||
"文件不存在: ": "文件不存在: ",
|
||||
"文件为空": "文件为空",
|
||||
"文件过大 (%d 字节,限制 %d 字节)": "文件过大 (%d 字节,限制 %d 字节)",
|
||||
"新标题": "新标题",
|
||||
"无法打开文件: %w": "无法打开文件: %w",
|
||||
"无法自动打开浏览器": "无法自动打开浏览器",
|
||||
"无法获取当前二进制路径: %w": "无法获取当前二进制路径: %w",
|
||||
@@ -78,10 +114,10 @@
|
||||
"无法解析符号链接: %w": "无法解析符号链接: %w",
|
||||
"无法读取当前二进制信息: %w": "无法读取当前二进制信息: %w",
|
||||
"无法连接到更新服务器: %w": "无法连接到更新服务器: %w",
|
||||
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。",
|
||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。",
|
||||
"服务端返回了空的 device_code 或 user_code": "服务端返回了空的 device_code 或 user_code",
|
||||
"服务端返回错误": "服务端返回错误",
|
||||
"服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。": "服务返回了空结果;请稍后重试,必要时查看 recovery snapshot。",
|
||||
"服务返回了空结果;请稍后重试。": "服务返回了空结果;请稍后重试。",
|
||||
"未找到 MCP Server URL": "未找到 MCP Server URL",
|
||||
"未找到认证信息,请运行 dws auth login": "未找到认证信息,请运行 dws auth login",
|
||||
"未登录,请运行 dws auth login": "未登录,请运行 dws auth login",
|
||||
@@ -90,42 +126,43 @@
|
||||
"本地文件路径 (必填)": "本地文件路径 (必填)",
|
||||
"权限不足;请检查当前身份是否有权限访问该服务或工具。": "权限不足;请检查当前身份是否有权限访问该服务或工具。",
|
||||
"构建上传请求失败: %w": "构建上传请求失败: %w",
|
||||
"查看任意命令的帮助信息": "查看任意命令的帮助信息",
|
||||
"查看插件详情": "查看插件详情",
|
||||
"查询入职人才": "查询入职人才",
|
||||
"查询在招岗位": "查询在招岗位",
|
||||
"修改待办任务": "修改待办任务",
|
||||
"修改执行者的待办完成状态": "修改执行者的待办完成状态",
|
||||
"优先级: 10低/20普通/30较高/40紧急": "优先级: 10低/20普通/30较高/40紧急",
|
||||
"查询待办列表": "查询待办列表",
|
||||
"待办": "待办",
|
||||
"待办任务 ID (必填)": "待办任务 ID (必填)",
|
||||
"待办任务管理": "待办任务管理",
|
||||
"待办标题 (必填)": "待办标题 (必填)",
|
||||
"待办详情": "待办详情",
|
||||
"循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1": "循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1",
|
||||
"截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)": "截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)",
|
||||
"执行者 userId 列表 (必填)": "执行者 userId 列表 (必填)",
|
||||
"检查登录状态后重试": "检查登录状态后重试",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令",
|
||||
"校验 plugin.json": "校验 plugin.json",
|
||||
"检查 CLI 授权状态失败": "检查 CLI 授权状态失败",
|
||||
"检查 internal/syncdata 静态端点生成物是否包含目标 server": "检查 internal/syncdata 静态端点生成物是否包含目标 server",
|
||||
"检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。": "检查服务连通性后重试;如持续失败,请确认 MCP 服务响应正常。",
|
||||
"检查服务连通性和协议版本后重试": "检查服务连通性和协议版本后重试",
|
||||
"检查登录状态后重试": "检查登录状态后重试",
|
||||
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
|
||||
"检查网络、代理和 DNS 配置后重试原命令": "检查网络、代理和 DNS 配置后重试原命令",
|
||||
"检查认证、权限和参数后重试原命令": "检查认证、权限和参数后重试原命令",
|
||||
"新标题": "新标题",
|
||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。",
|
||||
"步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n": "步骤 1/3: 准备上传 %s (%d 字节, %s)...\\\n",
|
||||
"步骤 2/3: 上传文件到 OSS...": "步骤 2/3: 上传文件到 OSS...",
|
||||
"步骤 3/3: 上传完成!": "步骤 3/3: 上传完成!",
|
||||
"用户拒绝了授权请求": "用户拒绝了授权请求",
|
||||
"完成状态: true/false": "完成状态: true/false",
|
||||
"完成状态: true=已完成, false=未完成 (必填)": "完成状态: true=已完成, false=未完成 (必填)",
|
||||
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
|
||||
"确认 MCP 服务可访问;若持续失败请稍后重试": "确认 MCP 服务可访问;若持续失败请稍后重试",
|
||||
"确认删除? (yes/no): ": "确认删除? (yes/no): ",
|
||||
"禁用插件": "禁用插件",
|
||||
"等待提交申请中": "等待提交申请中",
|
||||
"等待用户授权...": "等待用户授权...",
|
||||
"等待管理员审批中": "等待管理员审批中",
|
||||
"管理员操作入口:": "管理员操作入口:",
|
||||
"管理插件配置": "管理插件配置",
|
||||
"管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。": "管理钉钉个人待办:创建、查询列表、查看详情、修改、标记完成、删除。",
|
||||
"结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。": "结果格式与客户端预期不一致;请检查服务协议变更或回退到最近可用版本。",
|
||||
"网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。": "网络 I/O 超时。可通过 --timeout 增大超时时间,或检查网络连接。",
|
||||
"网络错误,继续重试...": "网络错误,继续重试...",
|
||||
"脚手架生成新插件目录": "脚手架生成新插件目录",
|
||||
"获取使用指引": "获取使用指引",
|
||||
"获取审批实例详情": "获取审批实例详情",
|
||||
"获取数量,超过 20 自动分页 (默认 20)": "获取数量,超过 20 自动分页 (默认 20)",
|
||||
"获取版本信息失败 (HTTP %d)": "获取版本信息失败 (HTTP %d)",
|
||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "覆盖 OAuth 客户端 ID (钉钉 AppKey)",
|
||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "覆盖 OAuth 客户端密钥 (钉钉 AppSecret)",
|
||||
"解析令牌数据失败": "解析令牌数据失败",
|
||||
"解析响应失败": "解析响应失败",
|
||||
"解析版本信息失败: %w": "解析版本信息失败: %w",
|
||||
@@ -138,59 +175,32 @@
|
||||
"设备授权流程失败(已重试 %d 次)": "设备授权流程失败(已重试 %d 次)",
|
||||
"设备授权码已过期": "设备授权码已过期",
|
||||
"设备授权码已过期(%d 秒),请重试": "设备授权码已过期(%d 秒),请重试",
|
||||
"设置插件配置项": "设置插件配置项",
|
||||
"设置权限失败: %w": "设置权限失败: %w",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
|
||||
"请在浏览器中完成扫码授权。": "请在浏览器中完成扫码授权。",
|
||||
"请在浏览器中打开以下链接,并输入授权码:": "请在浏览器中打开以下链接,并输入授权码:",
|
||||
"请检查服务 endpoint 是否为空或格式不合法。": "请检查服务 endpoint 是否为空或格式不合法。",
|
||||
"请检查网络连通性和 MCP 服务状态后重试。": "请检查网络连通性和 MCP 服务状态后重试。",
|
||||
"请求在重试过程中被取消;请检查调用侧超时设置。": "请求在重试过程中被取消;请检查调用侧超时设置。",
|
||||
"请求已取消。如果非手动取消,请检查调用侧超时设置。": "请求已取消。如果非手动取消,请检查调用侧超时设置。",
|
||||
"请求被上游服务拒绝;请检查参数、认证和权限配置。": "请求被上游服务拒绝;请检查参数、认证和权限配置。",
|
||||
"请求设备授权码...": "请求设备授权码...",
|
||||
"请求设备授权码失败": "请求设备授权码失败",
|
||||
"请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。": "请求超时(上下文截止时间已到)。可通过 --timeout 增大超时时间,或检查网络连接。",
|
||||
"读取 zip 条目失败: %w": "读取 zip 条目失败: %w",
|
||||
"读取响应失败": "读取响应失败",
|
||||
"读取版本信息失败: %w": "读取版本信息失败: %w",
|
||||
"跳过确认直接删除": "跳过确认直接删除",
|
||||
"--title 的别名": "--title 的别名",
|
||||
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "调用被拒绝;请检查认证状态、租户身份或访问权限。",
|
||||
"轮询过快,间隔增加至 %ds": "轮询过快,间隔增加至 %ds",
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken",
|
||||
"附件工作流": "附件工作流",
|
||||
"页码 (必填)": "页码 (必填)",
|
||||
"⚠️ 无法检查 CLI 数据访问权限状态": "⚠️ 无法检查 CLI 数据访问权限状态",
|
||||
" 请检查网络连接后重试。": " 请检查网络连接后重试。",
|
||||
"检查 CLI 授权状态失败": "检查 CLI 授权状态失败",
|
||||
"⚠️ 该组织尚未开启 CLI 数据访问权限": "⚠️ 该组织尚未开启 CLI 数据访问权限",
|
||||
" 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。": " 你所选择的组织管理员尚未开启「允许成员通过 CLI 访问其个人数据」的权限。",
|
||||
" 组织主管理员:": " 组织主管理员:",
|
||||
" 请联系组织主管理员开启后重新登录。": " 请联系组织主管理员开启后重新登录。",
|
||||
"管理员操作入口:": "管理员操作入口:",
|
||||
"该组织尚未开启 CLI 数据访问权限,请联系管理员开启": "该组织尚未开启 CLI 数据访问权限,请联系管理员开启",
|
||||
"⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...": "⏳ 该组织尚未开启 CLI 数据访问权限,请在浏览器中提交授权申请...",
|
||||
"✅ 权限已开启,继续登录...": "✅ 权限已开启,继续登录...",
|
||||
"等待管理员审批中": "等待管理员审批中",
|
||||
"等待提交申请中": "等待提交申请中",
|
||||
"操作超时,请重新登录": "操作超时,请重新登录",
|
||||
"检查组织 CLI 授权状态...": "检查组织 CLI 授权状态...",
|
||||
"🔐 登录钉钉": "🔐 登录钉钉",
|
||||
"插件管理": "插件管理",
|
||||
"列出已安装的插件": "列出已安装的插件",
|
||||
"安装插件": "安装插件",
|
||||
"查看插件详情": "查看插件详情",
|
||||
"启用插件": "启用插件",
|
||||
"禁用插件": "禁用插件",
|
||||
"卸载已安装的插件": "卸载已安装的插件",
|
||||
"校验 plugin.json": "校验 plugin.json",
|
||||
"脚手架生成新插件目录": "脚手架生成新插件目录",
|
||||
"将本地目录注册为开发态插件": "将本地目录注册为开发态插件",
|
||||
"管理插件配置": "管理插件配置",
|
||||
"设置插件配置项": "设置插件配置项",
|
||||
"读取插件配置项": "读取插件配置项",
|
||||
"列出插件所有配置项": "列出插件所有配置项",
|
||||
"删除插件配置项": "删除插件配置项",
|
||||
"将插件 stdio server 编译为原生二进制": "将插件 stdio server 编译为原生二进制",
|
||||
"覆盖 OAuth 客户端 ID (钉钉 AppKey)": "覆盖 OAuth 客户端 ID (钉钉 AppKey)",
|
||||
"覆盖 OAuth 客户端密钥 (钉钉 AppSecret)": "覆盖 OAuth 客户端密钥 (钉钉 AppSecret)",
|
||||
"查看任意命令的帮助信息": "查看任意命令的帮助信息",
|
||||
"显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。": "显示任意命令的帮助文案。\n用法:dws help [命令路径] 查看完整说明。"
|
||||
"读取版本信息失败: %w": "读取版本信息失败: %w",
|
||||
"调用被拒绝;请检查认证状态、租户身份或访问权限。": "调用被拒绝;请检查认证状态、租户身份或访问权限。",
|
||||
"跳过确认直接删除": "跳过确认直接删除",
|
||||
"轮询过快,间隔增加至 %ds": "轮询过快,间隔增加至 %ds",
|
||||
"运行 dws auth status 确认凭证有效,必要时重新登录": "运行 dws auth status 确认凭证有效,必要时重新登录",
|
||||
"运行 sync-oss 重新生成静态端点与路由后重试": "运行 sync-oss 重新生成静态端点与路由后重试",
|
||||
"返回数据缺少 uploadUrl 或 fileToken": "返回数据缺少 uploadUrl 或 fileToken",
|
||||
"连接被拒绝。请确认服务端已启动并正在监听。": "连接被拒绝。请确认服务端已启动并正在监听。",
|
||||
"附件工作流": "附件工作流",
|
||||
"静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。": "静态端点/协商失败;请检查网络、服务版本或升级到包含最新静态端点的 dws 版本。",
|
||||
"页码 (必填)": "页码 (必填)",
|
||||
"🔐 登录钉钉": "🔐 登录钉钉"
|
||||
}
|
||||
|
||||
+61
-23
@@ -32,9 +32,10 @@ import (
|
||||
// SetArgs so that Cobra's subsequent ExecuteC uses the corrected
|
||||
// values.
|
||||
//
|
||||
// If the target command cannot be resolved (e.g. the user typed a
|
||||
// non-existent command), PreParse is skipped silently and Cobra will
|
||||
// handle the error.
|
||||
// Explicit +shortcut tokens and annotated group subcommands are validated
|
||||
// before flag parsing so an unknown command cannot be misreported as an
|
||||
// unknown flag on its nearest parent. Other unresolved paths remain Cobra's
|
||||
// responsibility.
|
||||
func RunPreParse(root *cobra.Command, engine *Engine) error {
|
||||
_, err := RunPreParseArgs(root, engine, os.Args[1:])
|
||||
return err
|
||||
@@ -45,13 +46,17 @@ func RunPreParse(root *cobra.Command, engine *Engine) error {
|
||||
// exact same command traversal, FlagInfo extraction, handler chain, and
|
||||
// root.SetArgs delivery path.
|
||||
func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Context, error) {
|
||||
if engine == nil || !engine.HasHandlers(PreParse) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(rawArgs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
normalizedArgs, commandCorrection, fallbackErr := normalizeCommandPathFallback(root, engine, rawArgs)
|
||||
if fallbackErr != nil {
|
||||
ctx := &Context{Args: append([]string{}, rawArgs...)}
|
||||
if presentationErr := primeEarlyErrorPresentation(root, root, rawArgs); presentationErr != nil {
|
||||
slog.Debug("pipeline command-fallback presentation flags", "error", presentationErr)
|
||||
}
|
||||
return ctx, fallbackErr
|
||||
}
|
||||
|
||||
// Cobra's Traverse does not merge root persistent flags before deciding
|
||||
// whether a leading flag consumes the next token. For example, it can treat
|
||||
@@ -59,37 +64,52 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co
|
||||
// the unrelated root command `event list`. Remove only known root-persistent
|
||||
// flags from the traversal copy; the original argv remains intact for the
|
||||
// handlers and Cobra's real parse.
|
||||
target, _, err := root.Traverse(argsForCommandTraversal(root, rawArgs))
|
||||
target, remaining, err := root.Traverse(argsForCommandTraversal(root, normalizedArgs))
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if err := validateUnresolvedCommand(target, remaining); err != nil {
|
||||
ctx := &Context{
|
||||
Args: append([]string{}, normalizedArgs...),
|
||||
Command: target.CommandPath(),
|
||||
}
|
||||
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
|
||||
slog.Debug("pipeline command-resolution presentation flags", "error", presentationErr)
|
||||
}
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// Build FlagInfo from the target command's registered flags.
|
||||
flagInfos := FlagInfoFromCommand(target)
|
||||
if len(flagInfos) == 0 {
|
||||
if (engine == nil || !engine.HasHandlers(PreParse) || len(flagInfos) == 0) && commandCorrection == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ctx := &Context{
|
||||
Args: append([]string{}, rawArgs...),
|
||||
Args: append([]string{}, normalizedArgs...),
|
||||
// target.CommandPath() still carries the "dws" prefix; PreParse
|
||||
// handlers that key off a command (e.g. the semantic-alias table)
|
||||
// normalize it themselves, so the runtime key matches the build key.
|
||||
Command: target.CommandPath(),
|
||||
FlagSpecs: flagInfos,
|
||||
}
|
||||
if commandCorrection != nil {
|
||||
ctx.Corrections = append(ctx.Corrections, *commandCorrection)
|
||||
}
|
||||
|
||||
if err := engine.RunPhase(PreParse, ctx); err != nil {
|
||||
// Cobra has not parsed persistent flags yet, but this error is rendered
|
||||
// immediately by app.Execute. Prime only the presentation controls so
|
||||
// --format/--debug/--verbose affect this early error exactly as they do
|
||||
// errors returned after Cobra parsing. No credentials, profiles, output
|
||||
// paths, or execution controls are applied here.
|
||||
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
|
||||
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
|
||||
if engine != nil && engine.HasHandlers(PreParse) && len(flagInfos) > 0 {
|
||||
if err := engine.RunPhase(PreParse, ctx); err != nil {
|
||||
// Cobra has not parsed persistent flags yet, but this error is rendered
|
||||
// immediately by app.Execute. Prime only the presentation controls so
|
||||
// --format/--debug/--verbose affect this early error exactly as they do
|
||||
// errors returned after Cobra parsing. No credentials, profiles, output
|
||||
// paths, or execution controls are applied here.
|
||||
if presentationErr := primeEarlyErrorPresentation(root, target, ctx.Args); presentationErr != nil {
|
||||
slog.Debug("pipeline pre-parse presentation flags", "error", presentationErr)
|
||||
}
|
||||
slog.Debug("pipeline pre-parse", "error", err)
|
||||
return ctx, err
|
||||
}
|
||||
slog.Debug("pipeline pre-parse", "error", err)
|
||||
return ctx, err
|
||||
}
|
||||
|
||||
// Only set corrected args if PreParse actually changed something.
|
||||
@@ -109,25 +129,43 @@ func RunPreParseArgs(root *cobra.Command, engine *Engine, rawArgs []string) (*Co
|
||||
}
|
||||
|
||||
func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
|
||||
args, _ := argsForCommandTraversalWithPositions(root, rawArgs)
|
||||
return args
|
||||
}
|
||||
|
||||
func argsForCommandTraversalWithPositions(root *cobra.Command, rawArgs []string) ([]string, []int) {
|
||||
if root == nil || len(rawArgs) == 0 {
|
||||
return rawArgs
|
||||
positions := make([]int, len(rawArgs))
|
||||
for index := range positions {
|
||||
positions[index] = index
|
||||
}
|
||||
return rawArgs, positions
|
||||
}
|
||||
flags := root.PersistentFlags()
|
||||
if flags == nil || !flags.HasFlags() {
|
||||
return rawArgs
|
||||
positions := make([]int, len(rawArgs))
|
||||
for index := range positions {
|
||||
positions[index] = index
|
||||
}
|
||||
return rawArgs, positions
|
||||
}
|
||||
|
||||
matcher := newFlagTokenMatcher(flags)
|
||||
out := make([]string, 0, len(rawArgs))
|
||||
positions := make([]int, 0, len(rawArgs))
|
||||
for index := 0; index < len(rawArgs); index++ {
|
||||
argument := rawArgs[index]
|
||||
if argument == "--" {
|
||||
out = append(out, rawArgs[index:]...)
|
||||
for position := index; position < len(rawArgs); position++ {
|
||||
positions = append(positions, position)
|
||||
}
|
||||
break
|
||||
}
|
||||
flag, inlineValue, matched := matcher.matchTraversalToken(argument)
|
||||
if !matched {
|
||||
out = append(out, argument)
|
||||
positions = append(positions, index)
|
||||
continue
|
||||
}
|
||||
if index+1 < len(rawArgs) {
|
||||
@@ -140,7 +178,7 @@ func argsForCommandTraversal(root *cobra.Command, rawArgs []string) []string {
|
||||
index++
|
||||
}
|
||||
}
|
||||
return out
|
||||
return out, positions
|
||||
}
|
||||
|
||||
// separatedBoolValue recognises model-friendly `--flag false` and exact
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user