Compare 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 | ||
|
|
c280b19568 | ||
|
|
bb5065410e | ||
|
|
0353215b1d | ||
|
|
b94e21331d | ||
|
|
637a6f2f68 | ||
|
|
ae9ba333a0 | ||
|
|
6dbc8399df | ||
|
|
3c7ed03bd6 | ||
|
|
24cdb85d71 | ||
|
|
a1f8ecb7f0 | ||
|
|
a34ca5a137 | ||
|
|
7887b9473f | ||
|
|
fd3c82aa91 | ||
|
|
9266632694 | ||
|
|
b0cbcd7a04 | ||
|
|
6251117bd0 | ||
|
|
566803c431 | ||
|
|
d6848da60b | ||
|
|
5f5d7ee21e | ||
|
|
53169a41af | ||
|
|
e90d5bac68 | ||
|
|
d725bdbaa3 | ||
|
|
d75b744a35 | ||
|
|
9aea8c0b5c | ||
|
|
7134f33e1d | ||
|
|
f54b964d62 | ||
|
|
11fbeb4851 | ||
|
|
2b1f38edae | ||
|
|
2f3797c1f6 | ||
|
|
37fbb110e3 | ||
|
|
7564496ea0 | ||
|
|
a0b0d98180 | ||
|
|
990c85d36b | ||
|
|
b742343937 | ||
|
|
657df05d40 | ||
|
|
86f2b14449 | ||
|
|
86014c97cf | ||
|
|
94f3bba504 | ||
|
|
339eaa4b1b | ||
|
|
96774e6e23 | ||
|
|
1f77ba31f3 | ||
|
|
eb0bd69b82 | ||
|
|
665b79d8da | ||
|
|
32e7a80889 | ||
|
|
5e6b588b5e | ||
|
|
c1d90672a8 | ||
|
|
3d2d287723 | ||
|
|
969292a8d7 | ||
|
|
4bcf71fb9e | ||
|
|
ac610f2d24 | ||
|
|
98f45cfe23 | ||
|
|
fbf97ce402 | ||
|
|
f5b029b1a5 | ||
|
|
75f92cf546 | ||
|
|
6d485f47eb | ||
|
|
6651a162c5 | ||
|
|
a9aa39c3e2 | ||
|
|
545ee17316 | ||
|
|
0c62938f74 | ||
|
|
b0cd419f28 | ||
|
|
aa487002b7 | ||
|
|
941bf01e30 | ||
|
|
b439c5fa09 | ||
|
|
67250a9da5 | ||
|
|
70243acb95 | ||
|
|
6cce7fdbd8 | ||
|
|
fae21ec9f2 | ||
|
|
779fd82a88 | ||
|
|
3e60b83881 | ||
|
|
66a676b6aa | ||
|
|
23bbbccb7e | ||
|
|
45b43e52bb | ||
|
|
95a5cc42ce | ||
|
|
fec750b09e | ||
|
|
ddd5f15b91 | ||
|
|
db50be868b | ||
|
|
a6220d7d8b | ||
|
|
81bf0d2a6b | ||
|
|
fd61868393 | ||
|
|
f3a95d34a3 | ||
|
|
f1e95d763d | ||
|
|
9140015c42 | ||
|
|
de418c5696 | ||
|
|
5ec5b9811c | ||
|
|
378eaa377e | ||
|
|
d4368be1c3 | ||
|
|
9733acfb5a | ||
|
|
b1d422dcfd | ||
|
|
1231e500a3 | ||
|
|
c508968814 | ||
|
|
10943629d6 | ||
|
|
9fd38d9b9d | ||
|
|
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,86 @@ 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
|
||||
|
||||
- **Reviewed document shortcuts** (#880) — adds public document shortcuts for
|
||||
safe local downloads, content and history, review, media and style, and
|
||||
document access/sharing workflows, while retaining reviewed compatibility
|
||||
identities and confirmation safeguards for writes.
|
||||
- **Mentions in chat replies** (#881) — `chat message reply` now supports
|
||||
`--at-open-dingtalk-ids` and `--at-all`, forwarding reply mention fields and
|
||||
adding any required mention placeholders without changing existing send
|
||||
behavior.
|
||||
|
||||
## [1.0.57-beta.2] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stable Chat command compatibility** (#876) — restores the hidden migration
|
||||
entries for `chat send`, `chat history`, and their `im` aliases, preserving
|
||||
the v1.0.56 command surface while directing callers to the supported
|
||||
`chat message send/list` commands. Legacy flags now reach the same migration
|
||||
hints instead of failing during flag parsing.
|
||||
- **Drive download cancellation-test stability** (#876) — replaces a
|
||||
timing-sensitive worker-cancellation coverage test with a deterministic seam,
|
||||
reducing flaky CI without changing download behavior.
|
||||
|
||||
## [1.0.57-beta.1] - 2026-08-05
|
||||
|
||||
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
|
||||
@@ -56,9 +136,11 @@ 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
|
||||
|
||||
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
|
||||
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
|
||||
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
|
||||
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
|
||||
|
||||
@@ -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.56-beta.4"
|
||||
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.56-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f1f9b6394137edbd0b08d632aab34e92a0f3f81d80107a47de1bec9b384f0515"
|
||||
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.56-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "cd3c64d20723c420e2490405d0bf8eecfd7e2b8fc352f63f23de5847a1d38f55"
|
||||
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.56-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "910918d88074534e680a2e320d3cb364ad092e96b9c422f9e75d11c9c0815dd8"
|
||||
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.56-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "172fe0d84443be953d0c6f2c2433540e4b972fbe7776cff1417ec9c73723552b"
|
||||
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.56-beta.4/dws-skills.zip"
|
||||
sha256 "a3457befe858cbf3fe85848428b630bfd3a5f626256ed6b49415267948915152"
|
||||
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 查询业务数据。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-07-29T00:06:19.285348",
|
||||
"count": 265,
|
||||
"generated_at": "2026-08-05T22:43:52.497190",
|
||||
"count": 294,
|
||||
"results": [
|
||||
{
|
||||
"suite": "read",
|
||||
@@ -485,7 +485,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性。",
|
||||
"semantic_delta": "自动构造时间窗,分页拉取跨会话 @我 消息,并保留身份、引用、reaction、resourceRefs 与完整性;可选对资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -658,6 +658,16 @@
|
||||
"semantic_delta": "群邀请链接是一对一读取;Shortcut 未增加生命周期或分享编排。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "chat",
|
||||
"command": "+chat-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对齐 lark-cli +chat-list:默认仅群聊,支持 --types group/p2p、--exclude-muted、page-size/page-token 别名,并投影 openConversationId/name/conversationType;不宣称 sort 或 bot 身份 p2p 剥离。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "chat",
|
||||
@@ -715,7 +725,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一群聊与两类单聊目标,输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文。",
|
||||
"semantic_delta": "统一群聊与两类单聊目标;省略时间时自动以当前时间向前读取最近消息,并输出稳定消息身份、引用、reaction、resourceRefs、时间边界翻页和可读正文;可选对列表内资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1215,7 +1225,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 复用 HTTPS、相对路径、无覆盖和原子落盘防护,逐资源返回下载失败 ledger。",
|
||||
"semantic_delta": "按最多 50 个消息 ID 批量读取并输出稳定消息投影、reaction 与 resourceRefs;可用 --download-resources 统一下载 mediaId 与 fileId,复用受信任下载域、相对路径、无覆盖和原子落盘防护,对重复资源去重并逐资源返回下载失败 ledger;安全本地下载沿用 read/not_required 契约,不产生非交互确认盲区。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1295,7 +1305,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "把临时资源 URL 解析、工作目录内安全路径、默认不覆盖、临时文件下载和原子发布封装为结构化单步结果。",
|
||||
"semantic_delta": "统一承接消息 mediaId 与钉盘 fileId:分别复用 IM 临时资源 URL 和 drive.download_file,只允许钉钉/OSS HTTPS 下载域且重定向复验并隔离跨域凭据,再通过工作目录内安全路径、默认不覆盖、临时文件下载和原子发布输出结构化结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1315,7 +1325,7 @@
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 文本/Markdown 发送;按身份校验目标与凭据,幂等键只在真实支持的 user 分支开放,媒体上传仍诚实留在 native leaf。",
|
||||
"semantic_delta": "用统一 identity 参数路由 current-user、bot、webhook 发送;current-user 支持文本、Markdown、mediaId 图片、安全相对路径本地文件上传、userId 姓名解析与幂等键,bot/webhook 仍只暴露下层真实支持的文本/Markdown 能力。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1344,8 +1354,8 @@
|
||||
"command": "+messages-send-card",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "创建流式卡片是一对一写入;完整卡片生命周期需由 send/update leaf 明确编排。",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "既可只创建流式卡片,也可在一次调用中创建、提取 bizId、写入内容并设置流式状态;dry-run 输出两步执行计划,更新失败时保留已创建的 bizId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1415,7 +1425,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;支持精确时间窗、page-all、50 条一组 mget 富化,并以 failure ledger 显式报告截断或富化失败。",
|
||||
"semantic_delta": "统一关键词、发送者、@对象、会话、消息类型和机器人来源过滤;展开下层按会话分组的 conversationMessagesList,支持精确时间窗、page-all、50 条一组 mget 富化,可选安全下载命中消息资源,并以 failure ledger 显式报告截断、富化或下载失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1435,7 +1445,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页。",
|
||||
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -1708,123 +1718,454 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+access-change",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前权限后再变更角色,避免把不存在的协作者当作成功更新。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+access-grant",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在第一次写入前解析全部接收人,再批量授予文档权限并输出逐项 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+access-revoke",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "预检目标协作者权限后移除并输出逐项结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+background-delete",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以 clear 语义移除文档背景色。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+background-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "校验并设置 #RRGGBB 文档背景纯色。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+checkpoint-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "写入前保存版本快照,更新后读回验证并输出逐步 ledger。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-create",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "无 selection 创建全文评论,有 selection 时定位文本并创建划词评论。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "永久删除指定评论,并由静态安全契约强制确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一评论类型、解决状态与分页过滤。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-reply",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一评论回复、表情回复和 mention 参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+comment-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "更新指定评论正文与 mention。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+copy",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 复制入口,仅为兼容保留;新的文件复制应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 Markdown/JSONML 内容输入、目标位置与创建后保真写入。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+create-from-template",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "支持 templateId 直达或按名称搜索消歧后创建文档。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+doc-append",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文档末尾追加命令及其稳定 Schema identity;新场景优先使用 +update。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "按 jobId 查询导出状态的恢复入口;常规场景使用一体化 +export。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+export-submit",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "导出中断恢复所需的专家入口;常规场景使用一体化 +export。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+fetch",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 simple/with-ids/full 细节层级与 full/outline/range/section/keyword/tags 局部读取。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+find-doc",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史文档搜索命令及其稳定 Schema identity;新场景优先使用 +search。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+grant-and-share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先确保目标角色,再发送链接;消息失败保留逐人 ledger,并以非零退出报告 failed/partial_success。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+history-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一历史版本分页参数并返回可用于回滚的版本列表。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+history-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先验证目标版本存在,再执行回滚并读回当前文档状态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+history-save",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+import",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+inspect",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "聚合文档元信息,并按需读取样式、权限、历史、媒体和评论。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 导航入口,仅为兼容保留;新的文件树导航应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "解析附件临时链接并通过受控相对路径、no-clobber、原子发布安全下载。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-insert",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "组合本地文件校验、上传凭证、OSS PUT、插块和验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "从文档块中提取图片、附件及其 block/resource 标识。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+media-preview",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "将正文媒体下载到受控临时目录并返回本地预览 artifact。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+move",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "旧 Doc 移动入口,仅为兼容保留;新的文件移动应使用 Drive 命令。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+resource-delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "以幂等 clear 语义移除当前文档封面。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+resource-download",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前文档封面配置并安全下载资源到本地。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+resource-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "支持本地图片或 HTTPS 图片转存后设置文档封面。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+review",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "聚合未解决评论、划词引用和确定性上下文,不调用模型生成总结。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一关键词、最近访问、过滤、分页和稳定精简投影,作为文档定位的 canonical 入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+share",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析唯一用户后发送文档链接,不改变文档权限。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+share-doc",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史单人文档分享命令及其稳定 Schema identity;新场景优先使用 +share。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+template-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 MY/PUBLIC 模板浏览和分页参数。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称检索模板并返回可继续创建的 templateId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本列表命令及其稳定 Schema identity;新场景优先使用 +history-list。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-revert",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本回滚命令及其稳定 Schema identity;新场景优先使用 +history-revert。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "doc",
|
||||
"command": "+version-save",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留历史版本快照命令及其稳定 Schema identity;新场景优先使用 +history-save。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -72,6 +72,39 @@ func TestCalendarEventCreateHelpKeepsRoomsStringMetavar(t *testing.T) {
|
||||
|
||||
func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
for _, path := range []string{
|
||||
"chat send",
|
||||
"chat history",
|
||||
"im send",
|
||||
"im history",
|
||||
} {
|
||||
command, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil {
|
||||
t.Fatalf("find %s: %v", path, err)
|
||||
}
|
||||
if len(remaining) != 0 || !command.Hidden || !command.Runnable() {
|
||||
t.Fatalf("%s compatibility contract: remaining=%v hidden=%v runnable=%v", path, remaining, command.Hidden, command.Runnable())
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
args []string
|
||||
hint string
|
||||
}{
|
||||
{args: []string{"chat", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"im", "send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{args: []string{"chat", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
{args: []string{"im", "history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
command := NewRootCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(tc.args)
|
||||
err := command.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
|
||||
}
|
||||
}
|
||||
|
||||
listDirect := mustFindCommand(t, root, "chat", "message", "list-direct")
|
||||
for _, flag := range []string{"user", "open-dingtalk-id", "time", "forward", "limit"} {
|
||||
if listDirect.Flags().Lookup(flag) == nil {
|
||||
@@ -386,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 266
|
||||
publicShortcutCount = 294
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including hidden leaves such as minutes.shortcut_minutes_search.
|
||||
schemaPublishedShortcutCount = 267
|
||||
schemaPublishedShortcutCount = 295
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 266
|
||||
publiclyDeliveredShortcutCount = 294
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -194,17 +194,9 @@ func assertDeliveryShortcutSafetyAndInterface(
|
||||
canonical string,
|
||||
) {
|
||||
t.Helper()
|
||||
risk := declared.Risk
|
||||
if risk == "" {
|
||||
risk = shortcut.RiskRead
|
||||
}
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency := "read", "low", "not_required", "idempotent"
|
||||
switch risk {
|
||||
case shortcut.RiskWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "write", "medium", "user_required", "unknown"
|
||||
case shortcut.RiskHighWrite:
|
||||
wantEffect, wantRisk, wantConfirmation, wantIdempotency = "destructive", "high", "user_required", "unknown"
|
||||
}
|
||||
safety := shortcut.EffectiveSafety(declared)
|
||||
wantEffect, wantRisk := safety.Effect, safety.Risk
|
||||
wantConfirmation, wantIdempotency := safety.Confirmation, safety.Idempotency
|
||||
for field, want := range map[string]string{
|
||||
"effect": wantEffect,
|
||||
"risk": wantRisk,
|
||||
@@ -234,6 +226,15 @@ func assertDeliveryShortcutParameters(
|
||||
for _, flag := range declared.Flags {
|
||||
if !flag.Hidden {
|
||||
publicFlags = append(publicFlags, flag)
|
||||
if flag.AliasesVisible {
|
||||
for _, alias := range flag.Aliases {
|
||||
aliasFlag := flag
|
||||
aliasFlag.Name = alias
|
||||
aliasFlag.Default = ""
|
||||
aliasFlag.Aliases = nil
|
||||
publicFlags = append(publicFlags, aliasFlag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if got, want := len(parameters), len(publicFlags); got != want {
|
||||
@@ -299,6 +300,13 @@ func shortcutSchemaRequired(declared shortcut.Shortcut, flagName string) bool {
|
||||
if flag.Name == flagName && flag.Required {
|
||||
return true
|
||||
}
|
||||
if flag.Required && flag.AliasesVisible {
|
||||
for _, alias := range flag.Aliases {
|
||||
if alias == flagName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
public := make(map[string]bool, len(declared.Flags))
|
||||
for _, flag := range declared.Flags {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
+259
-67
@@ -54,6 +54,14 @@ func resolveMessageForward(cmd *cobra.Command, defaultForward bool) (bool, error
|
||||
}
|
||||
}
|
||||
|
||||
func chatCompatibilityHintSubCmd(use, hint string) *cobra.Command {
|
||||
command := hintSubCmd(use, hint)
|
||||
// Legacy callers may still pass the old command's flags. Let the migration
|
||||
// command consume them so Cobra reaches RunE and returns the replacement path.
|
||||
command.DisableFlagParsing = true
|
||||
return command
|
||||
}
|
||||
|
||||
type nativeChatTargetReader struct{}
|
||||
|
||||
func (nativeChatTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
@@ -396,6 +404,50 @@ func NormalizeMessageMentions(text string, ids []string, atAll, wrapAngle bool)
|
||||
return text
|
||||
}
|
||||
|
||||
// applyCurrentUserGroupMentions keeps the body placeholders and
|
||||
// send_personal_message mention arguments aligned for send and reply.
|
||||
func applyCurrentUserGroupMentions(params map[string]any, text, rawOpenIDs string, atAll bool) string {
|
||||
var atOpenIDs []string
|
||||
if rawOpenIDs != "" {
|
||||
atOpenIDs = strings.Split(rawOpenIDs, ",")
|
||||
}
|
||||
if atAll && !strings.Contains(text, "<@all>") {
|
||||
text = "<@all> " + text
|
||||
}
|
||||
text = normalizeAtPlaceholders(text, atOpenIDs, true)
|
||||
if atAll {
|
||||
params["atAll"] = true
|
||||
}
|
||||
if len(atOpenIDs) > 0 {
|
||||
params["atOpenDingTalkIds"] = atOpenIDs
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func addMissingCurrentUserMentionPlaceholders(text, rawOpenIDs string) string {
|
||||
if rawOpenIDs == "" {
|
||||
return text
|
||||
}
|
||||
missing := make([]string, 0)
|
||||
probeText := text
|
||||
for _, id := range parseCSVValues(rawOpenIDs) {
|
||||
placeholder := "<@" + id + ">"
|
||||
if strings.Contains(probeText, placeholder) {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, placeholder)
|
||||
probeText += placeholder
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return text
|
||||
}
|
||||
prefix := strings.Join(missing, " ")
|
||||
if strings.HasPrefix(text, "<@all> ") {
|
||||
return "<@all> " + prefix + " " + strings.TrimPrefix(text, "<@all> ")
|
||||
}
|
||||
return prefix + " " + text
|
||||
}
|
||||
|
||||
func containsMessageMention(text, placeholder string) bool {
|
||||
if strings.HasPrefix(placeholder, "<") {
|
||||
return strings.Contains(text, placeholder)
|
||||
@@ -1034,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"`
|
||||
@@ -2029,29 +2138,15 @@ func newChatCommand() *cobra.Command {
|
||||
if groupID != "" {
|
||||
atAll, _ := cmd.Flags().GetBool("at-all")
|
||||
atOpenIdsStr, _ := cmd.Flags().GetString("at-open-dingtalk-ids")
|
||||
var atOpenIds []string
|
||||
if atOpenIdsStr != "" {
|
||||
atOpenIds = strings.Split(atOpenIdsStr, ",")
|
||||
}
|
||||
if atAll && !strings.Contains(text, "<@all>") {
|
||||
text = "<@all> " + text
|
||||
}
|
||||
// 用户身份发消息要求 @ 占位符为 <@openDingTalkId>;模型若写成裸 @id 自动补全,已有 <@id> 不变
|
||||
text = normalizeAtPlaceholders(text, atOpenIds, true)
|
||||
// 群聊统一走 openDingTalkId @ 人接口。
|
||||
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||
newParams := map[string]any{
|
||||
"openConversationId": groupID,
|
||||
"msgType": "markdown",
|
||||
"content": string(contentJSON),
|
||||
"clawType": clawType,
|
||||
}
|
||||
if atAll {
|
||||
newParams["atAll"] = true
|
||||
}
|
||||
if len(atOpenIds) > 0 {
|
||||
newParams["atOpenDingTalkIds"] = atOpenIds
|
||||
}
|
||||
text = applyCurrentUserGroupMentions(newParams, text, atOpenIdsStr, atAll)
|
||||
contentJSON, _ := marshalJSONRaw(map[string]string{"title": title, "text": text})
|
||||
newParams["content"] = string(contentJSON)
|
||||
if msgUuid != "" {
|
||||
newParams["uuid"] = msgUuid
|
||||
}
|
||||
@@ -2105,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 请查收本周报告"
|
||||
@@ -2122,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")
|
||||
@@ -2135,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 != "" {
|
||||
@@ -2152,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
|
||||
}
|
||||
@@ -2174,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"
|
||||
@@ -2216,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"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -3532,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")
|
||||
@@ -5368,13 +5540,14 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
chatMessageReplyCmd := &cobra.Command{
|
||||
Use: "reply",
|
||||
Short: "引用回复消息(支持单聊/群聊)",
|
||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。
|
||||
Long: `以当前用户身份引用某条消息并回复。需要指定会话 ID、被引用消息 ID、原消息发送者 openDingTalkId,以及回复内容。群聊回复可通过 --at-open-dingtalk-ids @指定成员,或通过 --at-all @所有人;正文中的裸 @openDingTalkId 会自动规范化为 <@openDingTalkId>,缺少对应成员或 <@all> 占位符时会自动补齐。
|
||||
|
||||
如何获取 openConversationId(如果上层已有则直接使用,不必再查):
|
||||
- 群聊:dws chat search --query "群名"
|
||||
- 单聊:dws chat conversation-info --open-dingtalk-id <openDingTalkId>
|
||||
(人员信息可通过 dws contact user search --keyword "姓名" --format json 获取)`,
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
|
||||
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"
|
||||
dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "请看一下" --at-open-dingtalk-ids <mentionedOpenDingTalkId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "conversation-id", "ref-msg-id", "ref-sender", "text"); err != nil {
|
||||
return err
|
||||
@@ -5387,13 +5560,6 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
}
|
||||
refSender = resolved
|
||||
}
|
||||
replyContent := map[string]string{
|
||||
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
|
||||
"srcMsgSendOpenDingTalkId": refSender,
|
||||
"replyMsgType": "text",
|
||||
"content": mustGetFlag(cmd, "text"),
|
||||
}
|
||||
contentJSON, _ := marshalJSONRaw(replyContent)
|
||||
clawType := ""
|
||||
aiTag, _ := cmd.Flags().GetBool("ai-tag")
|
||||
if aiTag {
|
||||
@@ -5402,9 +5568,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "conversation-id"),
|
||||
"msgType": "reply",
|
||||
"content": string(contentJSON),
|
||||
"clawType": clawType,
|
||||
}
|
||||
atAll, _ := cmd.Flags().GetBool("at-all")
|
||||
atOpenIDs := mustGetFlag(cmd, "at-open-dingtalk-ids")
|
||||
replyText := applyCurrentUserGroupMentions(
|
||||
toolArgs,
|
||||
mustGetFlag(cmd, "text"),
|
||||
atOpenIDs,
|
||||
atAll,
|
||||
)
|
||||
replyText = addMissingCurrentUserMentionPlaceholders(replyText, atOpenIDs)
|
||||
replyContent := map[string]string{
|
||||
"referenceOpenMessageId": mustGetFlag(cmd, "ref-msg-id"),
|
||||
"srcMsgSendOpenDingTalkId": refSender,
|
||||
"replyMsgType": "text",
|
||||
"content": replyText,
|
||||
}
|
||||
contentJSON, _ := marshalJSONRaw(replyContent)
|
||||
toolArgs["content"] = string(contentJSON)
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
}
|
||||
@@ -5438,6 +5620,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "ai-tag", Property: "clawType", InterfaceType: "string"},
|
||||
{Name: "at-all", Property: "atAll", Required: boolPtr(false), InterfaceType: "boolean"},
|
||||
{Name: "at-open-dingtalk-ids", Property: "atOpenDingTalkIds", Required: boolPtr(false), InterfaceType: "array"},
|
||||
{Name: "conversation-id", Property: "openConversationId"},
|
||||
},
|
||||
},
|
||||
@@ -5452,6 +5636,8 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
_ = chatMessageReplyCmd.MarkFlagRequired("text")
|
||||
chatMessageReplyCmd.Flags().String("uuid", "", "幂等键(可选)")
|
||||
chatMessageReplyCmd.Flags().Bool("ai-tag", true, "消息是否带 AI 发送角标(默认 true)")
|
||||
chatMessageReplyCmd.Flags().Bool("at-all", false, "@所有人(仅群聊时生效;正文缺少 <@all> 时自动补齐)")
|
||||
chatMessageReplyCmd.Flags().String("at-open-dingtalk-ids", "", "@指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效;正文缺少对应 <@id> 时自动补齐,裸 @id 自动规范化)")
|
||||
cli.AttachRuntimeSchema(chatMessageReplyCmd, "chat", "reply_personal_message", "hardcoded:chat")
|
||||
|
||||
// ── message forward: 转发单条消息 ────────────────────────
|
||||
@@ -8166,5 +8352,11 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
|
||||
|
||||
// Keep the v1.0.56 command surface recognizable while directing callers to
|
||||
// the supported nested commands. The chat root's "im" alias makes these
|
||||
// compatibility hints available through both chat and im.
|
||||
root.AddCommand(chatCompatibilityHintSubCmd("send", "use: dws chat message send"))
|
||||
root.AddCommand(chatCompatibilityHintSubCmd("history", "use: dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"))
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -81,24 +81,33 @@ func TestCrossPlatformCoverageEvaluationRegressionChatSearchSpellingsAndNaturalB
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatMisroutedPathsRemainUnknownSubcommands(t *testing.T) {
|
||||
func TestCrossPlatformCoverageChatStableCompatibilityHintsRemainAvailable(t *testing.T) {
|
||||
root := newChatCommand()
|
||||
if len(root.Aliases) != 1 || root.Aliases[0] != "im" {
|
||||
t.Fatalf("chat aliases = %v, want [im]", root.Aliases)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
flag string
|
||||
args []string
|
||||
hint string
|
||||
}{
|
||||
{path: "send", flag: "--group"},
|
||||
{path: "history", flag: "--group"},
|
||||
{path: "send", args: []string{"send", "--group", "cid-stable", "--text", "hello"}, hint: "dws chat message send"},
|
||||
{path: "history", args: []string{"history", "--group", "cid-stable", "--limit", "20"}, hint: "dws chat message list --group <GROUP_OPEN_CONVERSATION_ID>"},
|
||||
} {
|
||||
caller := &productExampleCaller{}
|
||||
err := runChatCoverageCommand(t, caller, tc.path, tc.flag, "cid")
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown command") || !strings.Contains(err.Error(), tc.path) {
|
||||
t.Fatalf("chat %s error = %v, want unknown command", tc.path, err)
|
||||
command, remaining, err := root.Find([]string{tc.path})
|
||||
if err != nil {
|
||||
t.Fatalf("find chat %s: %v", tc.path, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("chat %s was misreported as a flag error: %v", tc.path, err)
|
||||
if len(remaining) != 0 || command.Name() != tc.path {
|
||||
t.Fatalf("find chat %s = command %q, remaining %v", tc.path, command.Name(), remaining)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("chat %s tool calls = %d, want 0", tc.path, caller.calls)
|
||||
if !command.Hidden || !command.Runnable() {
|
||||
t.Fatalf("chat %s compatibility contract: hidden=%v runnable=%v", tc.path, command.Hidden, command.Runnable())
|
||||
}
|
||||
root.SetArgs(tc.args)
|
||||
err = root.ExecuteContext(context.Background())
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("chat %s with legacy flags error = %v, want migration hint %q", tc.path, err, tc.hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -15,6 +15,7 @@ package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -285,6 +286,150 @@ func TestChatSendAndReplyDisableAITagWithEmptyClawType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatCurrentUserSendAndReplyMentions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
contentField string
|
||||
wantContent string
|
||||
wantAtAll bool
|
||||
wantOpenIDs []string
|
||||
}{
|
||||
{
|
||||
name: "send",
|
||||
args: []string{
|
||||
"message", "send", "--group", "cid",
|
||||
"--text", "收到 @D-target 和 <@D-second>",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "text",
|
||||
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target", "D-second"},
|
||||
},
|
||||
{
|
||||
name: "send keeps missing member placeholders unchanged",
|
||||
args: []string{
|
||||
"message", "send", "--group", "cid",
|
||||
"--text", "DWS 发消息自测",
|
||||
"--at-open-dingtalk-ids", "D-target",
|
||||
},
|
||||
contentField: "text",
|
||||
wantContent: "DWS 发消息自测",
|
||||
wantOpenIDs: []string{"D-target"},
|
||||
},
|
||||
{
|
||||
name: "reply",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "收到 @D-target 和 <@D-second>",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> 收到 <@D-target> 和 <@D-second>",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target", "D-second"},
|
||||
},
|
||||
{
|
||||
name: "reply adds missing member placeholders",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "DWS 回复艾特前津(非主用)自测",
|
||||
"--at-open-dingtalk-ids", "D-target,D-second,D-target",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@D-target> <@D-second> DWS 回复艾特前津(非主用)自测",
|
||||
wantOpenIDs: []string{"D-target", "D-second", "D-target"},
|
||||
},
|
||||
{
|
||||
name: "reply adds missing member placeholders after at-all",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "请大家确认",
|
||||
"--at-open-dingtalk-ids", "D-target",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> <@D-target> 请大家确认",
|
||||
wantAtAll: true,
|
||||
wantOpenIDs: []string{"D-target"},
|
||||
},
|
||||
{
|
||||
name: "reply at-all preserves alliance word",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "联系 @alliance",
|
||||
"--at-all",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "<@all> 联系 @alliance",
|
||||
wantAtAll: true,
|
||||
},
|
||||
{
|
||||
name: "reply without at flags preserves alliance word",
|
||||
args: []string{
|
||||
"message", "reply",
|
||||
"--conversation-id", "cid",
|
||||
"--ref-msg-id", "mid",
|
||||
"--ref-sender", "D-sender",
|
||||
"--text", "联系 @alliance",
|
||||
},
|
||||
contentField: "content",
|
||||
wantContent: "联系 @alliance",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
if err := executeChatChangedContract(t, caller, tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "send_personal_message" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
args := caller.calls[0].args
|
||||
gotAtAll, hasAtAll := args["atAll"]
|
||||
if tc.wantAtAll {
|
||||
if !hasAtAll || gotAtAll != true {
|
||||
t.Fatalf("atAll = %#v, present = %v; want true", gotAtAll, hasAtAll)
|
||||
}
|
||||
} else if hasAtAll {
|
||||
t.Fatalf("atAll = %#v; want absent", gotAtAll)
|
||||
}
|
||||
gotOpenIDs, hasOpenIDs := args["atOpenDingTalkIds"]
|
||||
if len(tc.wantOpenIDs) > 0 {
|
||||
if !hasOpenIDs || !reflect.DeepEqual(gotOpenIDs, tc.wantOpenIDs) {
|
||||
t.Fatalf("atOpenDingTalkIds = %#v, present = %v; want %#v", gotOpenIDs, hasOpenIDs, tc.wantOpenIDs)
|
||||
}
|
||||
} else if hasOpenIDs {
|
||||
t.Fatalf("atOpenDingTalkIds = %#v; want absent", gotOpenIDs)
|
||||
}
|
||||
var content map[string]string
|
||||
if err := json.Unmarshal([]byte(args["content"].(string)), &content); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := content[tc.contentField]; got != tc.wantContent {
|
||||
t.Fatalf("content[%q] = %q; want %q", tc.contentField, got, tc.wantContent)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatSendFailsClosedWhenUserCannotResolve(t *testing.T) {
|
||||
caller := &chatChangedContractCaller{}
|
||||
err := executeChatChangedContract(t, caller, "message", "send", "--user", "123", "--text", "hello")
|
||||
|
||||
@@ -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,24 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// RunDocImportShortcut exposes the existing, fully-tested Doc import pipeline
|
||||
// to the Shortcut application layer. The Cobra leaf still owns its own flags
|
||||
// and Contract; this bridge only shares the raw/API execution primitive.
|
||||
func RunDocImportShortcut(cmd *cobra.Command) error {
|
||||
return runImportCommand(cmd, nil, docImportFlowConfig())
|
||||
}
|
||||
|
||||
// RunDocMediaInsertShortcut shares the existing prepare + OSS PUT + block
|
||||
// insertion implementation with the canonical Doc Shortcut.
|
||||
func RunDocMediaInsertShortcut(cmd *cobra.Command) error {
|
||||
return runMediaInsert(cmd, nil)
|
||||
}
|
||||
|
||||
// RunDocResourceUpdateShortcut shares the cover upload/transfer pipeline.
|
||||
func RunDocResourceUpdateShortcut(cmd *cobra.Command) error {
|
||||
return runDocStyleCoverSet(cmd, nil)
|
||||
}
|
||||
@@ -66,6 +66,8 @@ var (
|
||||
driveFileStat = (*os.File).Stat
|
||||
)
|
||||
|
||||
var driveWorkerContextErr = func(ctx context.Context) error { return ctx.Err() }
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// HTTP 状态错误
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -629,7 +631,7 @@ func downloadRangedParts(ctx context.Context, creds *driveCredentialState, destP
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for part := range jobs {
|
||||
if runCtx.Err() != nil {
|
||||
if driveWorkerContextErr(runCtx) != nil {
|
||||
return
|
||||
}
|
||||
if err := downloadOnePart(runCtx, creds, f, part, totalSize); err != nil {
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -2868,48 +2870,32 @@ func TestCrossPlatformCoverageDriveDownloadVersionCancelNoResume(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageDriveTransferWorkerCtxCancelBeforeProcess(t *testing.T) {
|
||||
// 目标:覆盖 downloadRangedParts worker 中 "if runCtx.Err() != nil { return }"。
|
||||
// 策略:让 workers 正常处理分片,通过 context timeout 在处理过程中过期。
|
||||
// 当 worker 完成某个分片后循环回来收到新 job 时,发现 runCtx 已取消。
|
||||
// transport 每次请求加 50μs 延迟,使总处理时间接近 timeout,最大化命中率。
|
||||
|
||||
totalSize := int64(200)
|
||||
content := makeTestContent(int(totalSize))
|
||||
|
||||
origClient := driveRangeClient
|
||||
t.Cleanup(func() { driveRangeClient = origClient })
|
||||
|
||||
driveRangeClient = &http.Client{
|
||||
// 通过结构化 seam 让 worker 在收到唯一分片后确定性观察到取消状态;
|
||||
// 不再依赖微秒级 timeout 与 goroutine 调度概率。
|
||||
var checks atomic.Int32
|
||||
testseam.Swap(t, &driveWorkerContextErr, func(context.Context) error {
|
||||
checks.Add(1)
|
||||
return context.Canceled
|
||||
})
|
||||
var requests atomic.Int32
|
||||
testseam.Swap(t, &driveRangeClient, &http.Client{
|
||||
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
// 每次请求加小延迟,让总处理时间接近 deadline
|
||||
time.Sleep(50 * time.Microsecond)
|
||||
var start, end int64
|
||||
if _, err := fmt.Sscanf(req.Header.Get("Range"), "bytes=%d-%d", &start, &end); err != nil {
|
||||
return &http.Response{StatusCode: 400, Body: io.NopCloser(strings.NewReader("bad"))}, nil
|
||||
}
|
||||
if end >= int64(len(content)) {
|
||||
end = int64(len(content)) - 1
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusPartialContent,
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(string(content[start : end+1]))),
|
||||
}
|
||||
resp.Header.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(content)))
|
||||
return resp, nil
|
||||
requests.Add(1)
|
||||
return nil, errors.New("worker context guard did not stop the request")
|
||||
}),
|
||||
})
|
||||
|
||||
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
|
||||
dest := filepath.Join(t.TempDir(), "worker-context-guard.bin")
|
||||
opts := driveDownloadOptions{partSize: 1, parallel: 1, resume: false, knownSize: 1}
|
||||
if err := downloadRangedParts(context.Background(), creds, dest, 1, opts); err != nil {
|
||||
t.Fatalf("downloadRangedParts context guard: %v", err)
|
||||
}
|
||||
|
||||
// 多次尝试以确保覆盖(goroutine 调度非确定性)
|
||||
for attempt := 0; attempt < 50; attempt++ {
|
||||
// timeout 设为约为总处理时间的50%,确保在处理过程中过期
|
||||
// 40分片/4workers=10轮*50μs=500μs,timeout设300μs使其在中间过期
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Microsecond)
|
||||
|
||||
creds := &driveCredentialState{url: "http://127.0.0.1:1/fake"}
|
||||
dest := filepath.Join(t.TempDir(), fmt.Sprintf("wkr-%d.bin", attempt))
|
||||
opts := driveDownloadOptions{partSize: 5, parallel: 4, resume: false, knownSize: totalSize}
|
||||
_ = downloadRangedParts(ctx, creds, dest, totalSize, opts)
|
||||
cancel()
|
||||
if checks.Load() != 1 {
|
||||
t.Fatalf("worker context checks = %d, want 1", checks.Load())
|
||||
}
|
||||
if requests.Load() != 0 {
|
||||
t.Fatalf("worker requests = %d, want 0", requests.Load())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+20
-14
@@ -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"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
@@ -2334,12 +2335,13 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
|
||||
参数说明:
|
||||
--users 目标用户UID列表,逗号分隔(规范名),兼容 --uids
|
||||
--yes 跳过二次确认,直接执行分享
|
||||
--yes 确认执行此危险操作 (必填)
|
||||
|
||||
服务端可能返回风险提示(riskMessage)和 sign,此时需要用户确认后
|
||||
携带 sign 重新请求。默认会展示风险提示并中止,传入 --yes 可跳过确认。`,
|
||||
Example: ` dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1,uid2
|
||||
dws mail message share-to-chat --email user@company.com --id <messageId> --users uid1`,
|
||||
默认需要 --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
|
||||
@@ -2351,7 +2353,7 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
mcpArgs["uids"] = parseRecipients(users)
|
||||
}
|
||||
skipConfirm, _ := cmd.Flags().GetBool("yes")
|
||||
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.
|
||||
@@ -2361,10 +2363,18 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
if users := flagOrFallback(cmd, "users", "uids"); users != "" {
|
||||
deps.Out.PrintKeyValue("users", users)
|
||||
}
|
||||
deps.Out.PrintKeyValue("yes", fmt.Sprintf("%v", skipConfirm))
|
||||
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 {
|
||||
@@ -2378,12 +2388,8 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
firstResult = result
|
||||
}
|
||||
if sign, ok := firstResult["sign"].(string); ok && sign != "" {
|
||||
riskMsg, _ := firstResult["riskMessage"].(string)
|
||||
if !skipConfirm {
|
||||
if riskMsg != "" {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
|
||||
}
|
||||
return fmt.Errorf("服务端要求二次确认,请添加 --yes 参数确认后重新执行")
|
||||
if riskMsg, _ := firstResult["riskMessage"].(string); riskMsg != "" {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[风险提示] %s", riskMsg))
|
||||
}
|
||||
mcpArgs["sign"] = sign
|
||||
return callMCPTool("share_message_to_chat", mcpArgs)
|
||||
@@ -2431,7 +2437,7 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
messageShareToChatCmd.Flags().String("users", "", "目标用户UID列表,逗号分隔")
|
||||
messageShareToChatCmd.Flags().String("uids", "", "--users 的别名")
|
||||
_ = messageShareToChatCmd.Flags().MarkHidden("uids")
|
||||
messageShareToChatCmd.Flags().Bool("yes", false, "跳过二次确认,直接执行分享")
|
||||
messageShareToChatCmd.Flags().Bool("yes", false, "确认执行此危险操作 (必填)")
|
||||
|
||||
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
|
||||
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
|
||||
|
||||
@@ -442,21 +442,20 @@ func TestCrossPlatformCoverageMailExportShareAndAtomicWrite(t *testing.T) {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
|
||||
// Stdin "yes" satisfies deferred ConfirmSafety without leaf --yes,
|
||||
// so skipConfirm=false and the server-sign reconfirm branch runs.
|
||||
// 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 || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("expected server reconfirm, got %v", err)
|
||||
"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 || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Fatalf("expected reconfirm without riskMessage, got %v", err)
|
||||
"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{
|
||||
|
||||
@@ -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 版本。",
|
||||
"页码 (必填)": "页码 (必填)",
|
||||
"🔐 登录钉钉": "🔐 登录钉钉"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package localio owns safe local artifact publication shared by product
|
||||
// shortcuts. Remote names and URLs are always treated as untrusted input.
|
||||
package localio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
downloadTimeout = 10 * time.Minute
|
||||
maxDownloadBytes = int64(512 << 20)
|
||||
)
|
||||
|
||||
type downloadTempFile interface {
|
||||
io.Writer
|
||||
Sync() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
var (
|
||||
createDownloadTemp = createDownloadTempInRoot
|
||||
lookupDownloadIPs = net.DefaultResolver.LookupIPAddr
|
||||
dialDownloadIP = (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext
|
||||
localGetwd = os.Getwd
|
||||
localAbs = filepath.Abs
|
||||
localEvalSymlinks = filepath.EvalSymlinks
|
||||
openDownloadRoot = os.OpenRoot
|
||||
openDownloadParent = func(root *os.Root, name string) (*os.Root, error) { return root.OpenRoot(name) }
|
||||
downloadRootStat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Stat(name) }
|
||||
downloadRootLstat = func(root *os.Root, name string) (os.FileInfo, error) { return root.Lstat(name) }
|
||||
downloadRootMkdir = func(root *os.Root, name string, mode os.FileMode) error { return root.Mkdir(name, mode) }
|
||||
downloadRootLink = func(root *os.Root, oldName, newName string) error { return root.Link(oldName, newName) }
|
||||
downloadRootRemove = func(root *os.Root, name string) error { return root.Remove(name) }
|
||||
)
|
||||
|
||||
var downloadTempCounter atomic.Uint64
|
||||
|
||||
// DownloadOptions controls safe, atomic publication beneath BaseDir.
|
||||
type DownloadOptions struct {
|
||||
BaseDir string
|
||||
Output string
|
||||
PreferredName string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// DownloadResult describes the published local artifact.
|
||||
type DownloadResult struct {
|
||||
AbsolutePath string
|
||||
RelativePath string
|
||||
SizeBytes int64
|
||||
}
|
||||
|
||||
// Download validates a platform-owned HTTPS URL, resolves a workspace-relative
|
||||
// output path without following symlink escapes, streams into a sibling temp
|
||||
// file, fsyncs it, and atomically publishes the completed file.
|
||||
func Download(ctx context.Context, rawURL string, opts DownloadOptions) (DownloadResult, error) {
|
||||
return downloadWithClient(ctx, rawURL, opts, secureHTTPClient())
|
||||
}
|
||||
|
||||
func downloadWithClient(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client) (DownloadResult, error) {
|
||||
return downloadWithClientLimit(ctx, rawURL, opts, client, maxDownloadBytes)
|
||||
}
|
||||
|
||||
func downloadWithClientLimit(ctx context.Context, rawURL string, opts DownloadOptions, client *http.Client, maxBytes int64) (DownloadResult, error) {
|
||||
parsed, err := ValidateDownloadURL(rawURL)
|
||||
if err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
target, err := openDownloadTarget(opts.BaseDir, opts.Output, parsed.String(), opts.PreferredName)
|
||||
if err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
defer target.close()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) // URL was fully validated above
|
||||
for key, value := range opts.Headers {
|
||||
if strings.TrimSpace(key) != "" {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return DownloadResult{}, fmt.Errorf("下载资源失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return DownloadResult{}, fmt.Errorf("下载资源失败: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if resp.ContentLength > maxBytes {
|
||||
return DownloadResult{}, fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 响应大小 %d 超过上限 %d 字节", resp.ContentLength, maxBytes)
|
||||
}
|
||||
if err := target.verifyParent(); err != nil {
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
|
||||
tmp, tmpName, err := createDownloadTemp(target.parentRoot)
|
||||
if err != nil {
|
||||
return DownloadResult{}, fmt.Errorf("创建下载临时文件失败: %w", err)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = tmp.Close()
|
||||
_ = target.parentRoot.Remove(tmpName)
|
||||
}
|
||||
size, copyErr := io.Copy(tmp, io.LimitReader(resp.Body, maxBytes+1))
|
||||
if copyErr == nil && size > maxBytes {
|
||||
copyErr = fmt.Errorf("LOCAL_DOWNLOAD_TOO_LARGE: 下载内容超过上限 %d 字节", maxBytes)
|
||||
}
|
||||
if copyErr == nil {
|
||||
copyErr = tmp.Sync()
|
||||
}
|
||||
if closeErr := tmp.Close(); copyErr == nil {
|
||||
copyErr = closeErr
|
||||
}
|
||||
if copyErr != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, fmt.Errorf("写入下载临时文件失败: %w", copyErr)
|
||||
}
|
||||
if err := target.verifyParent(); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
if err := publishTempFile(target.parentRoot, tmpName, target.destinationName); err != nil {
|
||||
cleanup()
|
||||
return DownloadResult{}, err
|
||||
}
|
||||
return DownloadResult{AbsolutePath: target.absolutePath, RelativePath: filepath.ToSlash(target.relativePath), SizeBytes: size}, nil
|
||||
}
|
||||
|
||||
// ValidateOutput rejects absolute paths and portable `..` escapes.
|
||||
func ValidateOutput(output string) error {
|
||||
output = strings.TrimSpace(output)
|
||||
if output == "" {
|
||||
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不能为空")
|
||||
}
|
||||
portable := strings.ReplaceAll(output, "\\", "/")
|
||||
if filepath.IsAbs(output) || pathpkg.IsAbs(portable) ||
|
||||
(len(portable) >= 2 && portable[1] == ':' && ((portable[0] >= 'a' && portable[0] <= 'z') || (portable[0] >= 'A' && portable[0] <= 'Z'))) {
|
||||
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 只接受工作目录内的相对路径")
|
||||
}
|
||||
clean := pathpkg.Clean(portable)
|
||||
if clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 不允许使用 .. 逃逸工作目录")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveOutputPath returns a symlink-safe destination below baseDir.
|
||||
type downloadTarget struct {
|
||||
baseRoot *os.Root
|
||||
parentRoot *os.Root
|
||||
parentInfo os.FileInfo
|
||||
parentRelative string
|
||||
destinationName string
|
||||
absolutePath string
|
||||
relativePath string
|
||||
}
|
||||
|
||||
func (target *downloadTarget) close() {
|
||||
_ = target.parentRoot.Close()
|
||||
_ = target.baseRoot.Close()
|
||||
}
|
||||
|
||||
func (target *downloadTarget) verifyParent() error {
|
||||
current, err := downloadRootStat(target.baseRoot, target.parentRelative)
|
||||
if err != nil || !os.SameFile(target.parentInfo, current) {
|
||||
return fmt.Errorf("LOCAL_PATH_CHANGED: 下载期间输出目录被替换")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResolveOutputPath(baseDir, output, rawURL, preferredName string) (string, string, error) {
|
||||
target, err := openDownloadTarget(baseDir, output, rawURL, preferredName)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer target.close()
|
||||
return target.absolutePath, target.relativePath, nil
|
||||
}
|
||||
|
||||
func openDownloadTarget(baseDir, output, rawURL, preferredName string) (*downloadTarget, error) {
|
||||
if err := ValidateOutput(output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(baseDir) == "" {
|
||||
var err error
|
||||
baseDir, err = localGetwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取工作目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
absBase, err := localAbs(baseDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析工作目录失败: %w", err)
|
||||
}
|
||||
realBase, err := localEvalSymlinks(absBase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析工作目录失败: %w", err)
|
||||
}
|
||||
baseRoot, err := openDownloadRoot(realBase)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开工作目录失败: %w", err)
|
||||
}
|
||||
fail := func(err error) (*downloadTarget, error) {
|
||||
_ = baseRoot.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rawOutput := strings.TrimSpace(output)
|
||||
directoryIntent := rawOutput == "." || strings.HasSuffix(rawOutput, "/") || strings.HasSuffix(rawOutput, string(os.PathSeparator))
|
||||
candidate := filepath.Clean(rawOutput)
|
||||
if info, statErr := downloadRootStat(baseRoot, candidate); statErr == nil && info.IsDir() {
|
||||
directoryIntent = true
|
||||
} else if statErr != nil && !errors.Is(statErr, os.ErrNotExist) {
|
||||
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: 检查输出路径失败: %w", statErr))
|
||||
}
|
||||
if directoryIntent {
|
||||
candidate = filepath.Join(candidate, SafeFilename(preferredName, rawURL))
|
||||
}
|
||||
parent := filepath.Dir(candidate)
|
||||
if err := ensureSafeParent(baseRoot, parent); err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
parentRoot, err := openDownloadParent(baseRoot, parent)
|
||||
if err != nil {
|
||||
return fail(fmt.Errorf("固定输出目录失败: %w", err))
|
||||
}
|
||||
parentInfo, err := downloadRootStat(parentRoot, ".")
|
||||
if err != nil {
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("读取输出目录身份失败: %w", err))
|
||||
}
|
||||
currentParent, err := downloadRootStat(baseRoot, parent)
|
||||
if err != nil || !os.SameFile(parentInfo, currentParent) {
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("LOCAL_PATH_CHANGED: 输出目录在解析期间被替换"))
|
||||
}
|
||||
destinationName := filepath.Base(candidate)
|
||||
if info, statErr := downloadRootLstat(parentRoot, destinationName); statErr == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标不能是符号链接"))
|
||||
}
|
||||
if info.IsDir() {
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("LOCAL_PATH_UNSAFE: --output 目标是目录"))
|
||||
}
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在;请选择新的输出路径"))
|
||||
} else if !errors.Is(statErr, os.ErrNotExist) {
|
||||
_ = parentRoot.Close()
|
||||
return fail(fmt.Errorf("检查输出文件失败: %w", statErr))
|
||||
}
|
||||
return &downloadTarget{
|
||||
baseRoot: baseRoot,
|
||||
parentRoot: parentRoot,
|
||||
parentInfo: parentInfo,
|
||||
parentRelative: parent,
|
||||
destinationName: destinationName,
|
||||
absolutePath: filepath.Join(realBase, candidate),
|
||||
relativePath: candidate,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SafeFilename selects a portable basename from a preferred server name or URL.
|
||||
func SafeFilename(preferredName, rawURL string) string {
|
||||
if name := sanitizeFilename(preferredName); name != "" {
|
||||
return name
|
||||
}
|
||||
if parsed, err := url.Parse(rawURL); err == nil {
|
||||
if decoded, decodeErr := url.PathUnescape(filepath.Base(parsed.Path)); decodeErr == nil {
|
||||
if name := sanitizeFilename(decoded); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return "download"
|
||||
}
|
||||
|
||||
// ValidateDownloadURL accepts only public DingTalk and Aliyun OSS HTTPS hosts.
|
||||
func ValidateDownloadURL(rawURL string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil {
|
||||
return nil, fmt.Errorf("下载地址必须是受信任域名上的 HTTPS URL")
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
|
||||
if host == "" || net.ParseIP(host) != nil || !allowedDownloadHost(host) {
|
||||
return nil, fmt.Errorf("下载地址域名 %q 不属于受信任的钉钉或 OSS 域名", host)
|
||||
}
|
||||
if port := parsed.Port(); port != "" && port != "443" {
|
||||
return nil, fmt.Errorf("下载地址只允许 HTTPS 默认端口")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func secureHTTPClient() *http.Client {
|
||||
transport := &http.Transport{
|
||||
// Do not use environment proxies here. DialContext must resolve and dial
|
||||
// the validated download host itself; with a proxy it would receive the
|
||||
// proxy address and could not enforce the target host's public-IP policy.
|
||||
Proxy: nil,
|
||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ips, err := lookupDownloadIPs(ctx, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, resolved := range ips {
|
||||
if !publicIP(resolved.IP) {
|
||||
return nil, fmt.Errorf("下载域名解析到非公网地址 %s", resolved.IP)
|
||||
}
|
||||
}
|
||||
// Dial the already validated address, not the hostname, to avoid a
|
||||
// second DNS lookup opening a rebinding window.
|
||||
var lastErr error
|
||||
for _, resolved := range ips {
|
||||
conn, dialErr := dialDownloadIP(ctx, network, net.JoinHostPort(resolved.IP.String(), port))
|
||||
if dialErr == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = dialErr
|
||||
}
|
||||
return nil, lastErr
|
||||
},
|
||||
}
|
||||
client := &http.Client{Transport: transport, Timeout: downloadTimeout}
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("下载重定向次数超过上限")
|
||||
}
|
||||
if _, err := ValidateDownloadURL(req.URL.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
// net/http copies arbitrary request headers from the initial request to
|
||||
// every redirect. Never forward service-provided download credentials to
|
||||
// a different origin, even when both hosts are on the download allowlist.
|
||||
if len(via) > 0 && !sameDownloadOrigin(via[0].URL, req.URL) {
|
||||
req.Header = make(http.Header)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func sameDownloadOrigin(left, right *url.URL) bool {
|
||||
return downloadOrigin(left) == downloadOrigin(right)
|
||||
}
|
||||
|
||||
func downloadOrigin(parsed *url.URL) string {
|
||||
port := parsed.Port()
|
||||
if port == "" {
|
||||
port = "443"
|
||||
}
|
||||
host := strings.ToLower(strings.TrimSuffix(parsed.Hostname(), "."))
|
||||
return strings.ToLower(parsed.Scheme) + "://" + net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
func allowedDownloadHost(host string) bool {
|
||||
return host == "dingtalk.com" || strings.HasSuffix(host, ".dingtalk.com") ||
|
||||
(strings.HasSuffix(host, ".aliyuncs.com") && strings.Contains(host, "oss") && !strings.Contains(host, "internal"))
|
||||
}
|
||||
|
||||
func publicIP(ip net.IP) bool {
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsGlobalUnicast() || addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsMulticast() || addr.IsUnspecified() {
|
||||
return false
|
||||
}
|
||||
for _, prefix := range nonPublicPrefixes {
|
||||
if prefix.Contains(addr) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var nonPublicPrefixes = []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT
|
||||
netip.MustParsePrefix("192.0.0.0/24"), // IETF protocol assignments
|
||||
netip.MustParsePrefix("192.0.2.0/24"), // TEST-NET-1
|
||||
netip.MustParsePrefix("198.18.0.0/15"), // benchmark networks
|
||||
netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2
|
||||
netip.MustParsePrefix("203.0.113.0/24"), // TEST-NET-3
|
||||
netip.MustParsePrefix("240.0.0.0/4"), // reserved
|
||||
netip.MustParsePrefix("2001:db8::/32"), // IPv6 documentation
|
||||
}
|
||||
|
||||
func ensureSafeParent(root *os.Root, parent string) error {
|
||||
if parent == "." {
|
||||
return nil
|
||||
}
|
||||
current := "."
|
||||
for _, part := range strings.Split(parent, string(os.PathSeparator)) {
|
||||
current = filepath.Join(current, part)
|
||||
info, statErr := downloadRootLstat(root, current)
|
||||
if errors.Is(statErr, os.ErrNotExist) {
|
||||
if err := downloadRootMkdir(root, current, 0o755); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("创建输出目录失败: %w", err)
|
||||
}
|
||||
info, statErr = downloadRootLstat(root, current)
|
||||
}
|
||||
if statErr != nil {
|
||||
return fmt.Errorf("检查输出目录失败: %w", statErr)
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
return fmt.Errorf("LOCAL_PATH_UNSAFE: --output 父路径必须是非符号链接目录")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func createDownloadTempInRoot(root *os.Root) (downloadTempFile, string, error) {
|
||||
name := fmt.Sprintf(".dws-download-%d-%d", os.Getpid(), downloadTempCounter.Add(1))
|
||||
file, err := root.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return file, name, nil
|
||||
}
|
||||
|
||||
func publishTempFile(root *os.Root, tempName, destinationName string) error {
|
||||
if err := downloadRootLink(root, tempName, destinationName); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return fmt.Errorf("LOCAL_FILE_EXISTS: 目标文件已存在")
|
||||
}
|
||||
return fmt.Errorf("发布下载文件失败: %w", err)
|
||||
}
|
||||
if err := downloadRootRemove(root, tempName); err != nil {
|
||||
return fmt.Errorf("清理下载临时文件失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(raw string) string {
|
||||
normalized := strings.ReplaceAll(raw, "\\", "/")
|
||||
if strings.TrimSpace(normalized) != normalized {
|
||||
return ""
|
||||
}
|
||||
name := filepath.Base(normalized)
|
||||
if name == "" || name == "." || name == ".." || strings.HasSuffix(name, ".") || strings.HasSuffix(name, " ") {
|
||||
return ""
|
||||
}
|
||||
for _, char := range name {
|
||||
if char < 0x20 || char == 0x7f || strings.ContainsRune(`<>:"/\|?*`, char) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
stem := strings.ToUpper(strings.TrimRight(strings.SplitN(name, ".", 2)[0], " ."))
|
||||
if stem == "CON" || stem == "PRN" || stem == "AUX" || stem == "NUL" ||
|
||||
(len(stem) == 4 && (strings.HasPrefix(stem, "COM") || strings.HasPrefix(stem, "LPT")) && stem[3] >= '1' && stem[3] <= '9') {
|
||||
return ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,726 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package localio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) }
|
||||
|
||||
type failingBody struct{}
|
||||
|
||||
func (failingBody) Read([]byte) (int, error) { return 0, errors.New("read failed") }
|
||||
func (failingBody) Close() error { return nil }
|
||||
|
||||
type coverageTempFile struct {
|
||||
file *os.File
|
||||
writeErr error
|
||||
syncErr error
|
||||
closeErr error
|
||||
onClose func()
|
||||
}
|
||||
|
||||
func (f *coverageTempFile) Write(value []byte) (int, error) {
|
||||
if f.writeErr != nil {
|
||||
return 0, f.writeErr
|
||||
}
|
||||
return f.file.Write(value)
|
||||
}
|
||||
func (f *coverageTempFile) Name() string { return f.file.Name() }
|
||||
func (f *coverageTempFile) Sync() error {
|
||||
if f.syncErr != nil {
|
||||
return f.syncErr
|
||||
}
|
||||
return f.file.Sync()
|
||||
}
|
||||
func (f *coverageTempFile) Close() error {
|
||||
err := f.file.Close()
|
||||
if f.onClose != nil {
|
||||
f.onClose()
|
||||
f.onClose = nil
|
||||
}
|
||||
if f.closeErr != nil {
|
||||
return f.closeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadURLAndPublicIPPolicy(t *testing.T) {
|
||||
valid := []string{
|
||||
"https://alidocs.dingtalk.com/file.docx",
|
||||
"https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md",
|
||||
}
|
||||
for _, raw := range valid {
|
||||
if _, err := ValidateDownloadURL(raw); err != nil {
|
||||
t.Errorf("ValidateDownloadURL(%q): %v", raw, err)
|
||||
}
|
||||
}
|
||||
invalid := []string{
|
||||
"http://alidocs.dingtalk.com/file.docx",
|
||||
"https://127.0.0.1/file.docx",
|
||||
"https://evil.example/file.docx",
|
||||
"https://oss-cn-hangzhou-internal.aliyuncs.com/file.docx",
|
||||
"https://user@alidocs.dingtalk.com/file.docx",
|
||||
"https://alidocs.dingtalk.com:8443/file.docx",
|
||||
}
|
||||
for _, raw := range invalid {
|
||||
if _, err := ValidateDownloadURL(raw); err == nil {
|
||||
t.Errorf("ValidateDownloadURL(%q) unexpectedly succeeded", raw)
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range []string{"127.0.0.1", "10.0.0.1", "100.64.0.1", "192.0.2.1", "198.51.100.1", "203.0.113.1", "224.0.0.1", "2001:db8::1"} {
|
||||
if publicIP(net.ParseIP(raw)) {
|
||||
t.Errorf("publicIP(%s) = true", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"} {
|
||||
if !publicIP(net.ParseIP(raw)) {
|
||||
t.Errorf("publicIP(%s) = false", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOutputPathPolicy(t *testing.T) {
|
||||
for _, output := range []string{"", "../escape", "nested/../../escape", "/tmp/absolute", `C:\\absolute\\file`} {
|
||||
if err := ValidateOutput(output); err == nil {
|
||||
t.Errorf("ValidateOutput(%q) unexpectedly succeeded", output)
|
||||
}
|
||||
}
|
||||
|
||||
base := t.TempDir()
|
||||
destination, rel, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
realBase, err := filepath.EvalSymlinks(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel != filepath.Join("nested", "file.md") || filepath.Dir(destination) != filepath.Join(realBase, "nested") {
|
||||
t.Fatalf("destination=%q rel=%q", destination, rel)
|
||||
}
|
||||
if err := os.WriteFile(destination, []byte("existing"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := ResolveOutputPath(base, "nested/file.md", "https://alidocs.dingtalk.com/file.md", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
|
||||
t.Fatalf("no-clobber error = %v", err)
|
||||
}
|
||||
|
||||
outside := t.TempDir()
|
||||
link := filepath.Join(base, "outside-link")
|
||||
if err := os.Symlink(outside, link); err == nil {
|
||||
if _, _, err := ResolveOutputPath(base, "outside-link/file", "https://alidocs.dingtalk.com/file", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_UNSAFE") {
|
||||
t.Fatalf("symlink escape error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if got := SafeFilename("../evil", "https://alidocs.dingtalk.com/"); got != "evil" {
|
||||
t.Errorf("SafeFilename traversal basename = %q", got)
|
||||
}
|
||||
for _, name := range []string{"CON", "bad?.txt", " trailing.txt"} {
|
||||
if got := SafeFilename(name, "https://alidocs.dingtalk.com/"); got != "download" {
|
||||
t.Errorf("SafeFilename(%q) = %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadAtomicNoClobber(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
payload := "first payload"
|
||||
requests := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
requests++
|
||||
if req.URL.Host != "alidocs.oss-cn-zhangjiakou.aliyuncs.com" {
|
||||
return nil, errors.New("unexpected host")
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(payload)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
result, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{
|
||||
BaseDir: base, Output: "nested/result.md",
|
||||
}, client)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.RelativePath != "nested/result.md" || result.SizeBytes != int64(len(payload)) {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
got, err := os.ReadFile(result.AbsolutePath)
|
||||
if err != nil || string(got) != payload {
|
||||
t.Fatalf("published content = %q, err=%v", got, err)
|
||||
}
|
||||
if _, err := downloadWithClient(context.Background(), "https://alidocs.oss-cn-zhangjiakou.aliyuncs.com/res/file.md", DownloadOptions{
|
||||
BaseDir: base, Output: "nested/result.md",
|
||||
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
|
||||
t.Fatalf("second download error = %v", err)
|
||||
}
|
||||
if requests != 1 {
|
||||
t.Fatalf("existing destination performed %d network requests, want 1 total", requests)
|
||||
}
|
||||
|
||||
got, err = os.ReadFile(result.AbsolutePath)
|
||||
if err != nil || string(got) != payload {
|
||||
t.Fatalf("no-clobber content = %q, err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadSizeLimitCleansPartialFiles(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
contentLength int64
|
||||
}{
|
||||
{name: "declared", contentLength: 6},
|
||||
{name: "streamed", contentLength: -1},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader("123456")),
|
||||
Header: make(http.Header),
|
||||
ContentLength: tc.contentLength,
|
||||
}, nil
|
||||
})}
|
||||
output := tc.name + ".bin"
|
||||
if _, err := downloadWithClientLimit(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
|
||||
BaseDir: base, Output: output,
|
||||
}, client, 5); err == nil || !strings.Contains(err.Error(), "LOCAL_DOWNLOAD_TOO_LARGE") {
|
||||
t.Fatalf("oversized download error = %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, output)); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("oversized destination exists: %v", err)
|
||||
}
|
||||
entries, err := os.ReadDir(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-download-") {
|
||||
t.Fatalf("oversized download left temp file %q", entry.Name())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadRejectsParentReplacementDuringNetwork(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
parent := filepath.Join(base, "nested")
|
||||
if err := os.Mkdir(parent, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := filepath.Join(base, "original-parent")
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
if err := os.Rename(parent, original); err != nil {
|
||||
t.Skipf("platform cannot replace an open directory: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(parent, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
|
||||
BaseDir: base, Output: "nested/result.bin",
|
||||
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") {
|
||||
t.Fatalf("parent replacement error = %v", err)
|
||||
}
|
||||
for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} {
|
||||
if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("parent replacement wrote %q: %v", candidate, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadRejectsParentReplacementBeforePublish(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
parent := filepath.Join(base, "nested")
|
||||
if err := os.Mkdir(parent, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
original := filepath.Join(base, "original-parent")
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
testseam.Swap(t, &createDownloadTemp, func(root *os.Root) (downloadTempFile, string, error) {
|
||||
created, name, err := createDownloadTempInRoot(root)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &coverageTempFile{file: created.(*os.File), onClose: func() {
|
||||
if renameErr := os.Rename(parent, original); renameErr != nil {
|
||||
t.Skipf("platform cannot replace an open directory: %v", renameErr)
|
||||
}
|
||||
if mkdirErr := os.Mkdir(parent, 0o700); mkdirErr != nil {
|
||||
t.Fatal(mkdirErr)
|
||||
}
|
||||
}}, name, nil
|
||||
})
|
||||
if _, err := downloadWithClient(context.Background(), "https://download.dingtalk.com/file.bin", DownloadOptions{
|
||||
BaseDir: base, Output: "nested/result.bin",
|
||||
}, client); err == nil || !strings.Contains(err.Error(), "LOCAL_PATH_CHANGED") {
|
||||
t.Fatalf("parent replacement error = %v", err)
|
||||
}
|
||||
for _, candidate := range []string{filepath.Join(parent, "result.bin"), filepath.Join(original, "result.bin")} {
|
||||
if _, err := os.Stat(candidate); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("parent replacement wrote %q: %v", candidate, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDownloadFailureBoundaries(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
validURL := "https://download.dingtalk.com/file.bin"
|
||||
if _, err := Download(context.Background(), "bad", DownloadOptions{BaseDir: base, Output: "x"}); err == nil {
|
||||
t.Fatal("invalid URL download succeeded")
|
||||
}
|
||||
if _, err := Download(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "../x"}); err == nil {
|
||||
t.Fatal("unsafe output download succeeded")
|
||||
}
|
||||
|
||||
clientError := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { return nil, errors.New("transport") })}
|
||||
statusClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get("x-test") != "ok" || req.Header.Get("") != "" {
|
||||
t.Errorf("headers = %#v", req.Header)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("backend")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
bodyErrorClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: failingBody{}, Header: make(http.Header)}, nil
|
||||
})}
|
||||
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "transport.bin"}, clientError); err == nil {
|
||||
t.Fatal("transport error was ignored")
|
||||
}
|
||||
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "status.bin", Headers: map[string]string{"x-test": "ok", " ": "ignored"}}, statusClient); err == nil {
|
||||
t.Fatal("HTTP status error was ignored")
|
||||
}
|
||||
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: "copy.bin"}, bodyErrorClient); err == nil {
|
||||
t.Fatal("body read error was ignored")
|
||||
}
|
||||
|
||||
okClient := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("payload")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
makeTemp func(*os.Root) (downloadTempFile, string, error)
|
||||
}{
|
||||
{"create", func(*os.Root) (downloadTempFile, string, error) { return nil, "", errors.New("create") }},
|
||||
{"sync", func(root *os.Root) (downloadTempFile, string, error) {
|
||||
created, name, err := createDownloadTempInRoot(root)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &coverageTempFile{file: created.(*os.File), syncErr: errors.New("sync")}, name, nil
|
||||
}},
|
||||
{"close", func(root *os.Root) (downloadTempFile, string, error) {
|
||||
created, name, err := createDownloadTempInRoot(root)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &coverageTempFile{file: created.(*os.File), closeErr: errors.New("close")}, name, nil
|
||||
}},
|
||||
{"publish-race", func(root *os.Root) (downloadTempFile, string, error) {
|
||||
created, name, err := createDownloadTempInRoot(root)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &coverageTempFile{file: created.(*os.File), onClose: func() {
|
||||
file, createErr := root.OpenFile("publish-race.bin", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if createErr == nil {
|
||||
_ = file.Close()
|
||||
}
|
||||
}}, name, nil
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
testseam.Swap(t, &createDownloadTemp, tc.makeTemp)
|
||||
if _, err := downloadWithClient(context.Background(), validURL, DownloadOptions{BaseDir: base, Output: tc.name + ".bin"}, okClient); err == nil {
|
||||
t.Fatalf("%s failure was ignored", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSecureHTTPClientAndFilesystemEdges(t *testing.T) {
|
||||
client := secureHTTPClient()
|
||||
transport := client.Transport.(*http.Transport)
|
||||
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, make([]*http.Request, 5)); err == nil {
|
||||
t.Fatal("redirect limit accepted")
|
||||
}
|
||||
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://evil.example/x")}, nil); err == nil {
|
||||
t.Fatal("unsafe redirect accepted")
|
||||
}
|
||||
if err := client.CheckRedirect(&http.Request{URL: mustURL(t, "https://download.dingtalk.com/x")}, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "bad-address"); err == nil {
|
||||
t.Fatal("bad address dial succeeded")
|
||||
}
|
||||
|
||||
t.Run("lookup error", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) { return nil, errors.New("lookup") })
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
|
||||
t.Fatal("lookup error ignored")
|
||||
}
|
||||
})
|
||||
t.Run("private answer", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("127.0.0.1")}}, nil
|
||||
})
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
|
||||
t.Fatal("private DNS answer accepted")
|
||||
}
|
||||
})
|
||||
t.Run("public dial fallback and success", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}, {IP: net.ParseIP("1.1.1.1")}}, nil
|
||||
})
|
||||
left, right := net.Pipe()
|
||||
t.Cleanup(func() { _ = left.Close(); _ = right.Close() })
|
||||
calls := 0
|
||||
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return nil, errors.New("first")
|
||||
}
|
||||
return left, nil
|
||||
})
|
||||
if conn, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err != nil {
|
||||
t.Fatal(err)
|
||||
} else {
|
||||
_ = conn.Close()
|
||||
}
|
||||
})
|
||||
t.Run("all public dials fail", func(t *testing.T) {
|
||||
testseam.Swap(t, &lookupDownloadIPs, func(context.Context, string) ([]net.IPAddr, error) {
|
||||
return []net.IPAddr{{IP: net.ParseIP("8.8.8.8")}}, nil
|
||||
})
|
||||
testseam.Swap(t, &dialDownloadIP, func(context.Context, string, string) (net.Conn, error) { return nil, errors.New("dial") })
|
||||
if _, err := transport.DialContext(context.Background(), "tcp", "download.dingtalk.com:443"); err == nil {
|
||||
t.Fatal("dial failure ignored")
|
||||
}
|
||||
})
|
||||
|
||||
base := t.TempDir()
|
||||
if _, _, err := ResolveOutputPath("", "default-base.tmp", "https://download.dingtalk.com/x", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := ResolveOutputPath(filepath.Join(base, "missing"), "x", "https://download.dingtalk.com/x", ""); err == nil {
|
||||
t.Fatal("missing base succeeded")
|
||||
}
|
||||
dir := filepath.Join(base, "directory")
|
||||
if err := os.Mkdir(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, output := range []string{".", "directory/", "directory"} {
|
||||
if _, _, err := ResolveOutputPath(base, output, "https://download.dingtalk.com/path/name.txt", "preferred.txt"); err != nil {
|
||||
t.Errorf("directory output %q: %v", output, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := ResolveOutputPath(base, "directory", "https://download.dingtalk.com/x", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targetDir := filepath.Join(base, "target-dir")
|
||||
_ = os.Mkdir(targetDir, 0o700)
|
||||
if _, _, err := ResolveOutputPath(base, "target-dir", "https://download.dingtalk.com/x", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targetFile := filepath.Join(base, "existing.txt")
|
||||
_ = os.WriteFile(targetFile, []byte("x"), 0o600)
|
||||
if _, _, err := ResolveOutputPath(base, "existing.txt", "https://download.dingtalk.com/x", ""); err == nil || !strings.Contains(err.Error(), "LOCAL_FILE_EXISTS") {
|
||||
t.Fatalf("existing destination error = %v", err)
|
||||
}
|
||||
link := filepath.Join(base, "target-link")
|
||||
if err := os.Symlink(targetFile, link); err == nil {
|
||||
if _, _, err := ResolveOutputPath(base, "target-link", "https://download.dingtalk.com/x", ""); err == nil {
|
||||
t.Fatal("symlink destination accepted")
|
||||
}
|
||||
}
|
||||
fileParent := filepath.Join(base, "file-parent")
|
||||
_ = os.WriteFile(fileParent, []byte("x"), 0o600)
|
||||
if _, _, err := ResolveOutputPath(base, "file-parent/child", "https://download.dingtalk.com/x", ""); err == nil {
|
||||
t.Fatal("file parent accepted")
|
||||
}
|
||||
root, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = root.Close() })
|
||||
if err := ensureSafeParent(root, "../escape"); err == nil {
|
||||
t.Fatal("escaping parent accepted")
|
||||
}
|
||||
if err := ensureSafeParent(root, "."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
source, err := root.OpenFile("source.tmp", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = source.WriteString("x")
|
||||
_ = source.Close()
|
||||
destination := filepath.Join(base, "publish.txt")
|
||||
_ = os.WriteFile(destination, []byte("old"), 0o600)
|
||||
if err := publishTempFile(root, "source.tmp", "publish.txt"); err == nil {
|
||||
t.Fatal("publish existing destination succeeded")
|
||||
}
|
||||
if err := publishTempFile(root, "missing-source", "new.txt"); err == nil {
|
||||
t.Fatal("publish missing source succeeded")
|
||||
}
|
||||
symlinkDestination := filepath.Join(base, "publish-link")
|
||||
if err := os.Symlink(destination, symlinkDestination); err == nil {
|
||||
if err := publishTempFile(root, "source.tmp", "publish-link"); err == nil {
|
||||
t.Fatal("publish to symlink succeeded")
|
||||
}
|
||||
}
|
||||
closedRoot, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = closedRoot.Close()
|
||||
if _, _, err := createDownloadTempInRoot(closedRoot); err == nil {
|
||||
t.Fatal("temp creation in closed root succeeded")
|
||||
}
|
||||
|
||||
for _, name := range []string{"", ".", "..", "name.", "name ", "bad\x00", "AUX", "COM1", "LPT9"} {
|
||||
_ = sanitizeFilename(name)
|
||||
}
|
||||
_ = SafeFilename("", "https://download.dingtalk.com/path/fallback.txt")
|
||||
_ = SafeFilename("", "https://download.dingtalk.com/%zz")
|
||||
_ = SafeFilename("", "://bad")
|
||||
_ = publicIP(net.IP{1, 2, 3})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSecureHTTPClientDisablesEnvironmentProxy(t *testing.T) {
|
||||
t.Setenv("HTTPS_PROXY", "http://127.0.0.1:3128")
|
||||
transport := secureHTTPClient().Transport.(*http.Transport)
|
||||
if transport.Proxy != nil {
|
||||
t.Fatal("secure download client accepted an environment proxy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSecureHTTPClientStripsCrossOriginHeaders(t *testing.T) {
|
||||
client := secureHTTPClient()
|
||||
original := &http.Request{
|
||||
URL: mustURL(t, "https://download.dingtalk.com/source"),
|
||||
Header: http.Header{
|
||||
"X-Oss-Security-Token": []string{"credential-a"},
|
||||
"X-Download-Auth": []string{"credential-b"},
|
||||
},
|
||||
}
|
||||
|
||||
sameOrigin := &http.Request{
|
||||
URL: mustURL(t, "https://DOWNLOAD.dingtalk.com.:443/next"),
|
||||
Header: original.Header.Clone(),
|
||||
}
|
||||
if err := client.CheckRedirect(sameOrigin, []*http.Request{original}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sameOrigin.Header.Get("X-Oss-Security-Token") == "" {
|
||||
t.Fatal("same-origin redirect unexpectedly stripped request headers")
|
||||
}
|
||||
|
||||
crossOrigin := &http.Request{
|
||||
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/next"),
|
||||
Header: original.Header.Clone(),
|
||||
}
|
||||
if err := client.CheckRedirect(crossOrigin, []*http.Request{original}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(crossOrigin.Header) != 0 {
|
||||
t.Fatalf("cross-origin redirect retained %d request headers", len(crossOrigin.Header))
|
||||
}
|
||||
|
||||
multiHop := &http.Request{
|
||||
URL: mustURL(t, "https://attacker-bucket.oss-cn-hangzhou.aliyuncs.com/final"),
|
||||
Header: original.Header.Clone(),
|
||||
}
|
||||
if err := client.CheckRedirect(multiHop, []*http.Request{original, crossOrigin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(multiHop.Header) != 0 {
|
||||
t.Fatalf("later cross-origin redirect restored %d initial request headers", len(multiHop.Header))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFilesystemInjectedFailures(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
validURL := "https://download.dingtalk.com/x"
|
||||
cancelled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := Download(cancelled, validURL, DownloadOptions{BaseDir: base, Output: "default-client.bin"}); err == nil {
|
||||
t.Fatal("cancelled default client download succeeded")
|
||||
}
|
||||
|
||||
t.Run("getwd", func(t *testing.T) {
|
||||
testseam.Swap(t, &localGetwd, func() (string, error) { return "", errors.New("getwd") })
|
||||
_, _, _ = ResolveOutputPath("", "x", validURL, "")
|
||||
})
|
||||
t.Run("abs", func(t *testing.T) {
|
||||
testseam.Swap(t, &localAbs, func(string) (string, error) { return "", errors.New("abs") })
|
||||
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
|
||||
})
|
||||
t.Run("eval base", func(t *testing.T) {
|
||||
testseam.Swap(t, &localEvalSymlinks, func(string) (string, error) { return "", errors.New("eval") })
|
||||
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
|
||||
})
|
||||
t.Run("open base", func(t *testing.T) {
|
||||
testseam.Swap(t, &openDownloadRoot, func(string) (*os.Root, error) { return nil, errors.New("open root") })
|
||||
_, _, _ = ResolveOutputPath(base, "x", validURL, "")
|
||||
})
|
||||
t.Run("mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &downloadRootMkdir, func(*os.Root, string, os.FileMode) error { return errors.New("mkdir") })
|
||||
_, _, _ = ResolveOutputPath(base, "new/target", validURL, "")
|
||||
})
|
||||
t.Run("lstat after mkdir", func(t *testing.T) {
|
||||
calls := 0
|
||||
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "new-after" {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
return nil, errors.New("after mkdir")
|
||||
}
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "new-after/target", validURL, "")
|
||||
})
|
||||
t.Run("open parent", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "open-parent"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &openDownloadParent, func(*os.Root, string) (*os.Root, error) { return nil, errors.New("open parent") })
|
||||
_, _, _ = ResolveOutputPath(base, "open-parent/target", validURL, "")
|
||||
})
|
||||
t.Run("parent stat", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "parent-stat"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "." {
|
||||
return nil, errors.New("parent stat")
|
||||
}
|
||||
return root.Stat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "parent-stat/target", validURL, "")
|
||||
})
|
||||
t.Run("parent identity", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "parent-identity"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherInfo, err := os.Stat(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &downloadRootStat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "parent-identity" {
|
||||
return otherInfo, nil
|
||||
}
|
||||
return root.Stat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "parent-identity/target", validURL, "")
|
||||
})
|
||||
t.Run("destination directory", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "destination-directory"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dirInfo, err := os.Stat(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "target" {
|
||||
return dirInfo, nil
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "destination-directory/target", validURL, "")
|
||||
})
|
||||
t.Run("destination symlink", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "destination-symlink"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
link := filepath.Join(base, "coverage-link")
|
||||
if err := os.Symlink(filepath.Join(base, "destination-symlink"), link); err != nil {
|
||||
t.Skipf("symlink unavailable: %v", err)
|
||||
}
|
||||
linkInfo, err := os.Lstat(link)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "target" {
|
||||
return linkInfo, nil
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "destination-symlink/target", validURL, "")
|
||||
})
|
||||
t.Run("destination lstat", func(t *testing.T) {
|
||||
if err := os.Mkdir(filepath.Join(base, "destination-lstat"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &downloadRootLstat, func(root *os.Root, name string) (os.FileInfo, error) {
|
||||
if name == "target" {
|
||||
return nil, errors.New("destination lstat")
|
||||
}
|
||||
return root.Lstat(name)
|
||||
})
|
||||
_, _, _ = ResolveOutputPath(base, "destination-lstat/target", validURL, "")
|
||||
})
|
||||
t.Run("unsafe parent type", func(t *testing.T) {
|
||||
filePath := filepath.Join(base, "unsafe-parent")
|
||||
if err := os.WriteFile(filePath, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
if err := ensureSafeParent(root, "unsafe-parent"); err == nil {
|
||||
t.Fatal("regular file accepted as output parent")
|
||||
}
|
||||
})
|
||||
t.Run("publish remove", func(t *testing.T) {
|
||||
root, err := os.OpenRoot(base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer root.Close()
|
||||
file, err := root.OpenFile("remove-source", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = file.Close()
|
||||
testseam.Swap(t, &downloadRootRemove, func(*os.Root, string) error { return errors.New("remove") })
|
||||
if err := publishTempFile(root, "remove-source", "remove-destination"); err == nil {
|
||||
t.Fatal("publish remove error ignored")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
commandPathFallbackModeRewrite = "rewrite"
|
||||
commandPathFallbackModeAmbiguous = "ambiguous"
|
||||
)
|
||||
|
||||
// normalizeCommandPathFallback applies only an exact reviewed path match. It
|
||||
// runs before Cobra resolves a leaf, then the canonical argv continues through
|
||||
// the ordinary parameter alias, validation, safety, and execution pipeline.
|
||||
func normalizeCommandPathFallback(root *cobra.Command, engine *Engine, rawArgs []string) ([]string, *Correction, error) {
|
||||
if root == nil || engine == nil || engine.commandPathFallbackLookup == nil || len(rawArgs) == 0 {
|
||||
return rawArgs, nil, nil
|
||||
}
|
||||
traversalArgs, positions := argsForCommandTraversalWithPositions(root, rawArgs)
|
||||
entry, tokenCount, ok := longestCommandPathFallback(engine, traversalArgs)
|
||||
if !ok {
|
||||
return rawArgs, nil, nil
|
||||
}
|
||||
// Runtime extensions are mounted on the root before PreParse runs. An exact
|
||||
// executable command (or exact Cobra alias) is user/runtime authority and
|
||||
// must win over the reviewed recovery dictionary. Hint-only compatibility
|
||||
// nodes are not executable business commands, so fallback may supersede
|
||||
// them as it does for the distribution-owned declaration tree.
|
||||
if exactRunnableCommandPath(root, traversalArgs[:tokenCount]) {
|
||||
return rawArgs, nil, nil
|
||||
}
|
||||
|
||||
switch entry.Mode {
|
||||
case commandPathFallbackModeRewrite:
|
||||
if strings.TrimSpace(entry.To) == "" {
|
||||
return rawArgs, nil, apperrors.NewInternal(fmt.Sprintf("reviewed command path fallback %q has no rewrite target", entry.From))
|
||||
}
|
||||
rewritten := rewriteCommandPathTokens(rawArgs, positions[:tokenCount], strings.Fields(entry.To))
|
||||
correction := &Correction{
|
||||
Handler: "command-path-fallback",
|
||||
Phase: PreParse,
|
||||
Field: "command_path",
|
||||
Original: entry.From,
|
||||
Corrected: entry.To,
|
||||
Kind: "reviewed-fallback",
|
||||
}
|
||||
return rewritten, correction, nil
|
||||
case commandPathFallbackModeAmbiguous:
|
||||
return rawArgs, nil, ambiguousCommandPathFallbackError(root, entry)
|
||||
default:
|
||||
return rawArgs, nil, apperrors.NewInternal(fmt.Sprintf("reviewed command path fallback %q has invalid mode %q", entry.From, entry.Mode))
|
||||
}
|
||||
}
|
||||
|
||||
func exactRunnableCommandPath(root *cobra.Command, path []string) bool {
|
||||
if root == nil || len(path) == 0 {
|
||||
return false
|
||||
}
|
||||
current := root
|
||||
if path[0] == root.Name() {
|
||||
path = path[1:]
|
||||
}
|
||||
if len(path) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, token := range path {
|
||||
var next *cobra.Command
|
||||
for _, child := range current.Commands() {
|
||||
if child.Name() == token || containsExactCommandAlias(child.Aliases, token) {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return false
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current.Runnable() && !cmdutil.IsHintOnlyCommand(current)
|
||||
}
|
||||
|
||||
func containsExactCommandAlias(aliases []string, token string) bool {
|
||||
for _, alias := range aliases {
|
||||
if alias == token {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func longestCommandPathFallback(engine *Engine, traversalArgs []string) (CommandPathFallback, int, bool) {
|
||||
var selected CommandPathFallback
|
||||
selectedCount := 0
|
||||
parts := make([]string, 0, len(traversalArgs))
|
||||
for index, argument := range traversalArgs {
|
||||
argument = strings.TrimSpace(argument)
|
||||
if argument == "" || argument == "--" || strings.HasPrefix(argument, "-") {
|
||||
break
|
||||
}
|
||||
parts = append(parts, argument)
|
||||
if candidate, ok := engine.lookupCommandPathFallback(strings.Join(parts, " ")); ok {
|
||||
selected = candidate
|
||||
selectedCount = index + 1
|
||||
}
|
||||
}
|
||||
return selected, selectedCount, selectedCount > 0
|
||||
}
|
||||
|
||||
func rewriteCommandPathTokens(rawArgs []string, commandPositions []int, canonicalTokens []string) []string {
|
||||
if len(commandPositions) == 0 || len(canonicalTokens) == 0 {
|
||||
return append([]string(nil), rawArgs...)
|
||||
}
|
||||
remove := make(map[int]bool, len(commandPositions))
|
||||
for _, position := range commandPositions {
|
||||
remove[position] = true
|
||||
}
|
||||
insertAt := commandPositions[0]
|
||||
out := make([]string, 0, len(rawArgs)-len(commandPositions)+len(canonicalTokens))
|
||||
for index, argument := range rawArgs {
|
||||
if index == insertAt {
|
||||
out = append(out, canonicalTokens...)
|
||||
}
|
||||
if remove[index] {
|
||||
continue
|
||||
}
|
||||
out = append(out, argument)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ambiguousCommandPathFallbackError(root *cobra.Command, entry CommandPathFallback) error {
|
||||
rootName := "dws"
|
||||
if root != nil && strings.TrimSpace(root.Name()) != "" {
|
||||
rootName = root.Name()
|
||||
}
|
||||
paths := make([]string, 0, len(entry.Candidates))
|
||||
actions := make([]string, 0, len(entry.Candidates))
|
||||
for _, candidate := range entry.Candidates {
|
||||
path := strings.TrimSpace(rootName + " " + candidate)
|
||||
paths = append(paths, fmt.Sprintf("%q", path))
|
||||
actions = append(actions, fmt.Sprintf("Run '%s --help' to inspect this candidate", path))
|
||||
}
|
||||
hint := "Inspect the reviewed candidates and choose by the original user intent: " + strings.Join(paths, ", ")
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("command path %q is ambiguous", strings.TrimSpace(rootName+" "+entry.From)),
|
||||
apperrors.WithReason("ambiguous_command_fallback"),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithActions(actions...),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsRewritesReviewedCommandBeforeFlags(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
raw := []string{"--format", "json", "chat", "+bad", "--query", "project"}
|
||||
ctx, err := RunPreParseArgs(root, engine, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantArgs := []string{"--format", "json", "chat", "+good", "--query", "project"}
|
||||
if ctx == nil || !reflect.DeepEqual(ctx.Args, wantArgs) || ctx.Command != "dws chat +good" {
|
||||
t.Fatalf("context = %#v, want args=%v command=dws chat +good", ctx, wantArgs)
|
||||
}
|
||||
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Handler != "command-path-fallback" ||
|
||||
ctx.Corrections[0].Original != "chat +bad" || ctx.Corrections[0].Corrected != "chat +good" {
|
||||
t.Fatalf("corrections = %#v", ctx.Corrections)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *executed != "dws chat +good:project" {
|
||||
t.Fatalf("executed = %q", *executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsRewritesMultiTokenPathAroundPersistentFlags(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat group search": {From: "chat group search", Mode: "rewrite", To: "chat search"},
|
||||
})
|
||||
raw := []string{"chat", "--format", "json", "group", "search", "--query", "project"}
|
||||
ctx, err := RunPreParseArgs(root, engine, raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantArgs := []string{"chat", "search", "--format", "json", "--query", "project"}
|
||||
if ctx == nil || !reflect.DeepEqual(ctx.Args, wantArgs) {
|
||||
t.Fatalf("rewritten args = %#v, want %v", ctx, wantArgs)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *executed != "dws chat search:project" {
|
||||
t.Fatalf("executed = %q", *executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackDefersToExactRunnableCommandAndAlias(t *testing.T) {
|
||||
if exactRunnableCommandPath(nil, []string{"dws"}) {
|
||||
t.Fatal("nil root unexpectedly resolved an executable path")
|
||||
}
|
||||
emptyRoot := &cobra.Command{Use: "dws"}
|
||||
if exactRunnableCommandPath(emptyRoot, []string{"dws"}) {
|
||||
t.Fatal("root-only path unexpectedly resolved as an executable leaf")
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "primary", path: "+members"},
|
||||
{name: "alias", path: "+runtime-members"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
executed := ""
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
members := &cobra.Command{
|
||||
Use: "+members",
|
||||
Aliases: []string{"+runtime-members"},
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
sentinel, _ := cmd.Flags().GetString("sentinel")
|
||||
executed = cmd.CommandPath() + ":" + sentinel
|
||||
return nil
|
||||
},
|
||||
}
|
||||
members.Flags().String("sentinel", "", "")
|
||||
chat.AddCommand(members)
|
||||
root.AddCommand(chat)
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat " + test.path: {From: "chat " + test.path, Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
args := []string{"chat", test.path, "--sentinel", "fixture"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := RunPreParseArgs(root, engine, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx != nil {
|
||||
t.Fatalf("runtime command unexpectedly entered correction pipeline: %#v", ctx)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if executed != "dws chat +members:fixture" {
|
||||
t.Fatalf("runtime command execution = %q", executed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackMaySupersedeHintOnlyCommand(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
chat, _, err := root.Find([]string{"chat"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chat.AddCommand(cmdutil.HintSubCmd("+bad", "use the reviewed recovery"))
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
args := []string{"chat", "+bad", "--query", "project"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := RunPreParseArgs(root, engine, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +good" || len(ctx.Corrections) != 1 {
|
||||
t.Fatalf("hint-only fallback context = %#v", ctx)
|
||||
}
|
||||
if _, err := root.ExecuteC(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *executed != "dws chat +good:project" {
|
||||
t.Fatalf("hint-only fallback execution = %q", *executed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsRejectsAmbiguousCommandFallbackWithoutDispatch(t *testing.T) {
|
||||
root, executed := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +choose": {
|
||||
From: "chat +choose",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +good", "chat +other"},
|
||||
},
|
||||
})
|
||||
ctx, err := RunPreParseArgs(root, engine, []string{"--format", "json", "chat", "+choose", "--query", "project"})
|
||||
if ctx == nil {
|
||||
t.Fatal("ambiguous fallback returned nil context")
|
||||
}
|
||||
structured := requireCommandResolutionError(t, err, "ambiguous_command_fallback")
|
||||
if len(structured.Actions) != 2 || !strings.Contains(structured.Hint, "dws chat +good") || !strings.Contains(structured.Hint, "dws chat +other") {
|
||||
t.Fatalf("ambiguous recovery = %#v", structured)
|
||||
}
|
||||
if *executed != "" {
|
||||
t.Fatalf("ambiguous fallback dispatched %q", *executed)
|
||||
}
|
||||
if format, getErr := root.PersistentFlags().GetString("format"); getErr != nil || format != "json" {
|
||||
t.Fatalf("format = %q, %v; want primed json", format, getErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackLeavesUnknownAndCanonicalFlagErrorsDistinct(t *testing.T) {
|
||||
root, _ := commandFallbackPipelineRoot()
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +bad": {From: "chat +bad", Mode: "rewrite", To: "chat +good"},
|
||||
})
|
||||
_, err := RunPreParseArgs(root, engine, []string{"chat", "+missing", "--query", "project"})
|
||||
requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
|
||||
validRoot, _ := commandFallbackPipelineRoot()
|
||||
validRaw := []string{"chat", "+good", "--not-a-flag", "value"}
|
||||
if ctx, err := RunPreParseArgs(validRoot, engine, validRaw); ctx != nil || err != nil {
|
||||
t.Fatalf("valid command preparse = %#v, %v", ctx, err)
|
||||
}
|
||||
validRoot.SetArgs(validRaw)
|
||||
_, executeErr := validRoot.ExecuteC()
|
||||
if executeErr == nil || !strings.Contains(executeErr.Error(), "unknown flag") {
|
||||
t.Fatalf("canonical bad flag error = %v", executeErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackCannotBypassCanonicalConfirmation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantDispatch int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "confirmation missing", args: []string{"chat", "+danger"}, wantErr: true},
|
||||
{name: "confirmed", args: []string{"chat", "+danger", "--yes"}, wantDispatch: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
dispatches := 0
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
write := &cobra.Command{
|
||||
Use: "+write",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
confirmed, _ := cmd.Flags().GetBool("yes")
|
||||
if !confirmed {
|
||||
return apperrors.NewValidation("confirmation required", apperrors.WithReason("confirmation_required"))
|
||||
}
|
||||
dispatches++
|
||||
return nil
|
||||
},
|
||||
}
|
||||
write.Flags().Bool("yes", false, "")
|
||||
chat.AddCommand(write)
|
||||
root.AddCommand(chat)
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +danger": {From: "chat +danger", Mode: "rewrite", To: "chat +write"},
|
||||
})
|
||||
if _, err := RunPreParseArgs(root, engine, test.args); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := root.ExecuteC()
|
||||
if (err != nil) != test.wantErr || dispatches != test.wantDispatch {
|
||||
t.Fatalf("execute error=%v dispatches=%d, want error=%v dispatches=%d", err, dispatches, test.wantErr, test.wantDispatch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandPathFallbackRejectsInvalidGeneratedMode(t *testing.T) {
|
||||
root, _ := commandFallbackPipelineRoot()
|
||||
for _, entry := range []CommandPathFallback{
|
||||
{From: "chat +bad", Mode: "rewrite"},
|
||||
{From: "chat +bad", Mode: "invalid", To: "chat +good"},
|
||||
} {
|
||||
engine := commandFallbackPipelineEngine(map[string]CommandPathFallback{"chat +bad": entry})
|
||||
_, err := RunPreParseArgs(root, engine, []string{"chat", "+bad"})
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryInternal {
|
||||
t.Fatalf("invalid entry error = %T %v", err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsKeepsCommandErrorWhenPresentationPrimingFails(t *testing.T) {
|
||||
fallbackRoot, _ := commandFallbackPipelineRoot()
|
||||
fallbackEngine := commandFallbackPipelineEngine(map[string]CommandPathFallback{
|
||||
"chat +choose": {
|
||||
From: "chat +choose",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"chat +good", "chat +other"},
|
||||
},
|
||||
})
|
||||
_, err := RunPreParseArgs(fallbackRoot, fallbackEngine, []string{"chat", "+choose", "--format"})
|
||||
requireCommandResolutionError(t, err, "ambiguous_command_fallback")
|
||||
|
||||
resolutionRoot, _ := commandFallbackPipelineRoot()
|
||||
_, err = RunPreParseArgs(resolutionRoot, nil, []string{"chat", "+missing", "--format"})
|
||||
requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
|
||||
handlerRoot, _ := commandFallbackPipelineRoot()
|
||||
handlerEngine := NewEngine()
|
||||
handlerEngine.Register(newStub("fail-before-presentation", PreParse, func(*Context) error {
|
||||
return errors.New("handler failure")
|
||||
}))
|
||||
ctx, err := RunPreParseArgs(handlerRoot, handlerEngine, []string{"chat", "+good", "--query", "project", "--format"})
|
||||
if ctx == nil || err == nil || !strings.Contains(err.Error(), "handler failure") {
|
||||
t.Fatalf("handler error after presentation failure = context %#v, error %v", ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackTraversalRemainingBranches(t *testing.T) {
|
||||
args, positions := argsForCommandTraversalWithPositions(nil, []string{"chat", "+good"})
|
||||
if !reflect.DeepEqual(args, []string{"chat", "+good"}) || !reflect.DeepEqual(positions, []int{0, 1}) {
|
||||
t.Fatalf("nil-root traversal = args %v, positions %v", args, positions)
|
||||
}
|
||||
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
raw := []string{"chat", "+good", "--", "--format", "json"}
|
||||
args, positions = argsForCommandTraversalWithPositions(root, raw)
|
||||
if !reflect.DeepEqual(args, raw) || !reflect.DeepEqual(positions, []int{0, 1, 2, 3, 4}) {
|
||||
t.Fatalf("terminator traversal = args %v, positions %v", args, positions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRewriteCommandPathTokensLeavesArgsWhenRewriteIsEmpty(t *testing.T) {
|
||||
raw := []string{"chat", "+bad", "--query", "project"}
|
||||
for name, test := range map[string]struct {
|
||||
positions []int
|
||||
tokens []string
|
||||
}{
|
||||
"no command positions": {tokens: []string{"chat", "+good"}},
|
||||
"no canonical tokens": {positions: []int{0, 1}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := rewriteCommandPathTokens(raw, test.positions, test.tokens)
|
||||
if !reflect.DeepEqual(got, raw) {
|
||||
t.Fatalf("rewriteCommandPathTokens() = %v, want %v", got, raw)
|
||||
}
|
||||
got[0] = "mutated"
|
||||
if raw[0] != "chat" {
|
||||
t.Fatal("empty rewrite returned aliased argv")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func commandFallbackPipelineEngine(entries map[string]CommandPathFallback) *Engine {
|
||||
engine := NewEngine()
|
||||
engine.SetCommandPathFallbackLookup(func(path string) (CommandPathFallback, bool) {
|
||||
entry, ok := entries[path]
|
||||
return entry, ok
|
||||
})
|
||||
return engine
|
||||
}
|
||||
|
||||
func commandFallbackPipelineRoot() (*cobra.Command, *string) {
|
||||
executed := new(string)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
good := &cobra.Command{
|
||||
Use: "+good",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
query, _ := cmd.Flags().GetString("query")
|
||||
*executed = cmd.CommandPath() + ":" + query
|
||||
return nil
|
||||
},
|
||||
}
|
||||
good.Flags().String("query", "", "")
|
||||
good.Flags().String("keyword", "", "")
|
||||
other := &cobra.Command{Use: "+other", Run: func(cmd *cobra.Command, _ []string) { *executed = cmd.CommandPath() }}
|
||||
search := &cobra.Command{
|
||||
Use: "search",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
query, _ := cmd.Flags().GetString("query")
|
||||
*executed = cmd.CommandPath() + ":" + query
|
||||
return nil
|
||||
},
|
||||
}
|
||||
search.Flags().String("query", "", "")
|
||||
group := &cobra.Command{Use: "group"}
|
||||
group.AddCommand(cmdutil.HintSubCmd("search", "use dws chat search"))
|
||||
chat.AddCommand(good, other, search, group)
|
||||
root.AddCommand(chat)
|
||||
return root, executed
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const maxCommandSuggestions = 3
|
||||
|
||||
// validateUnresolvedCommand gives command identity precedence over flag
|
||||
// parsing. Cobra otherwise falls back to the nearest parent when a child is
|
||||
// unknown, so a later flag can incorrectly turn an invented command into an
|
||||
// "unknown flag" error on that parent.
|
||||
//
|
||||
// Only two unambiguous command containers are handled here:
|
||||
// - a top-level service followed by an explicit +shortcut token; and
|
||||
// - a command explicitly annotated as a group container.
|
||||
//
|
||||
// Other unresolved positionals remain Cobra's responsibility so commands that
|
||||
// legitimately accept positional arguments are not reclassified.
|
||||
func validateUnresolvedCommand(target *cobra.Command, remaining []string) error {
|
||||
if target == nil || len(remaining) == 0 {
|
||||
return nil
|
||||
}
|
||||
candidate := strings.TrimSpace(remaining[0])
|
||||
if candidate == "" || candidate == "--" || strings.HasPrefix(candidate, "-") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isExplicitShortcutCandidate(target, candidate) {
|
||||
return unknownShortcutError(target, candidate)
|
||||
}
|
||||
if cmdutil.IsGroup(target) {
|
||||
return unknownSubcommandError(target, candidate)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isExplicitShortcutCandidate(target *cobra.Command, candidate string) bool {
|
||||
if target == nil || !strings.HasPrefix(candidate, "+") {
|
||||
return false
|
||||
}
|
||||
root := target.Root()
|
||||
if root == nil || target == root || target.Parent() != root {
|
||||
return false
|
||||
}
|
||||
for _, child := range target.Commands() {
|
||||
if strings.HasPrefix(child.Name(), "+") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unknownShortcutError(parent *cobra.Command, candidate string) error {
|
||||
action := fmt.Sprintf("Run '%s shortcut list --service %s --format json'", parent.Root().Name(), parent.Name())
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("unknown shortcut %q for %q", candidate, parent.CommandPath()),
|
||||
apperrors.WithReason("unknown_shortcut"),
|
||||
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
|
||||
apperrors.WithActions(action),
|
||||
)
|
||||
}
|
||||
|
||||
func unknownSubcommandError(parent *cobra.Command, candidate string) error {
|
||||
action := fmt.Sprintf("Run '%s --help' to see available subcommands", parent.CommandPath())
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("unknown subcommand %q for %q", candidate, parent.CommandPath()),
|
||||
apperrors.WithReason("unknown_subcommand"),
|
||||
apperrors.WithHint(commandSuggestionHint(parent, candidate, action)),
|
||||
apperrors.WithActions(action),
|
||||
)
|
||||
}
|
||||
|
||||
func commandSuggestionHint(parent *cobra.Command, candidate, fallback string) string {
|
||||
suggestions := commandSuggestions(parent, candidate)
|
||||
if len(suggestions) > maxCommandSuggestions {
|
||||
suggestions = suggestions[:maxCommandSuggestions]
|
||||
}
|
||||
paths := make([]string, 0, len(suggestions))
|
||||
for _, suggestion := range suggestions {
|
||||
paths = append(paths, fmt.Sprintf("%q", parent.CommandPath()+" "+suggestion))
|
||||
}
|
||||
switch len(paths) {
|
||||
case 0:
|
||||
return fallback
|
||||
case 1:
|
||||
return "Did you mean " + paths[0] + "?"
|
||||
default:
|
||||
return "Did you mean one of: " + strings.Join(paths, ", ") + "?"
|
||||
}
|
||||
}
|
||||
|
||||
// commandSuggestions mirrors Cobra's suggestion rules without mutating
|
||||
// SuggestionsMinimumDistance. Cobra only installs its default distance inside
|
||||
// the final error renderer; this guard runs earlier and must remain safe for
|
||||
// command trees shared by concurrent tests and callers.
|
||||
func commandSuggestions(parent *cobra.Command, candidate string) []string {
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
minimumDistance := parent.SuggestionsMinimumDistance
|
||||
if minimumDistance <= 0 {
|
||||
minimumDistance = 2
|
||||
}
|
||||
lowerCandidate := strings.ToLower(candidate)
|
||||
shortcutOnly := strings.HasPrefix(candidate, "+")
|
||||
seen := make(map[string]bool)
|
||||
var suggestions []string
|
||||
for _, child := range parent.Commands() {
|
||||
if !child.IsAvailableCommand() {
|
||||
continue
|
||||
}
|
||||
name := child.Name()
|
||||
if shortcutOnly && !strings.HasPrefix(name, "+") {
|
||||
continue
|
||||
}
|
||||
matched := cmdutil.LevenshteinDist(lowerCandidate, strings.ToLower(name)) <= minimumDistance ||
|
||||
strings.HasPrefix(strings.ToLower(name), lowerCandidate)
|
||||
if !matched {
|
||||
for _, explicit := range child.SuggestFor {
|
||||
if strings.EqualFold(candidate, explicit) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched && !seen[name] {
|
||||
seen[name] = true
|
||||
suggestions = append(suggestions, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(suggestions)
|
||||
return suggestions
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesShortcutBeforeFlags(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
chat.AddCommand(
|
||||
&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "+messages-send", Run: func(*cobra.Command, []string) {}},
|
||||
&cobra.Command{Use: "chat-mesages", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
root.AddCommand(chat)
|
||||
|
||||
err := validateUnresolvedCommand(chat, []string{"+chat-mesages", "--keyword", "x"})
|
||||
structured := requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
if structured.Message != `unknown shortcut "+chat-mesages" for "dws chat"` {
|
||||
t.Fatalf("Message = %q", structured.Message)
|
||||
}
|
||||
if structured.Hint != `Did you mean "dws chat +chat-messages"?` {
|
||||
t.Fatalf("Hint = %q", structured.Hint)
|
||||
}
|
||||
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws shortcut list --service chat --format json'" {
|
||||
t.Fatalf("Actions = %#v", structured.Actions)
|
||||
}
|
||||
if len(structured.AvailableFlags) != 0 {
|
||||
t.Fatalf("AvailableFlags = %#v, want none for command error", structured.AvailableFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateUnresolvedCommandClassifiesOnlyExplicitContainers(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
dev := &cobra.Command{Use: "dev"}
|
||||
app := &cobra.Command{Use: "app"}
|
||||
cmdutil.MarkGroup(app)
|
||||
app.AddCommand(&cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}})
|
||||
dev.AddCommand(app)
|
||||
root.AddCommand(dev)
|
||||
|
||||
err := validateUnresolvedCommand(app, []string{"search", "--keyword", "x"})
|
||||
structured := requireCommandResolutionError(t, err, "unknown_subcommand")
|
||||
if structured.Message != `unknown subcommand "search" for "dws dev app"` {
|
||||
t.Fatalf("Message = %q", structured.Message)
|
||||
}
|
||||
if len(structured.Actions) != 1 || structured.Actions[0] != "Run 'dws dev app --help' to see available subcommands" {
|
||||
t.Fatalf("Actions = %#v", structured.Actions)
|
||||
}
|
||||
|
||||
positional := &cobra.Command{Use: "schema [path]", Run: func(*cobra.Command, []string) {}}
|
||||
root.AddCommand(positional)
|
||||
for name, test := range map[string]struct {
|
||||
target *cobra.Command
|
||||
remaining []string
|
||||
}{
|
||||
"legitimate positional": {target: positional, remaining: []string{"+chat-messages"}},
|
||||
"flag remains a flag": {target: app, remaining: []string{"--keyword", "x"}},
|
||||
"dash terminator": {target: app, remaining: []string{"--", "search"}},
|
||||
"nil target": {target: nil, remaining: []string{"search"}},
|
||||
"empty remaining": {target: app},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := validateUnresolvedCommand(test.target, test.remaining); err != nil {
|
||||
t.Fatalf("validateUnresolvedCommand() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPreParseArgsValidatesCommandsWithoutHandlersAndPrimesPresentation(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.PersistentFlags().String("format", "table", "")
|
||||
chat := &cobra.Command{Use: "chat"}
|
||||
chat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}})
|
||||
root.AddCommand(chat)
|
||||
|
||||
ctx, err := RunPreParseArgs(root, nil, []string{
|
||||
"--format", "json", "chat", "+chat-mesages", "--keyword", "x",
|
||||
})
|
||||
if ctx == nil || ctx.Command != "dws chat" {
|
||||
t.Fatalf("Context = %#v", ctx)
|
||||
}
|
||||
requireCommandResolutionError(t, err, "unknown_shortcut")
|
||||
if format, getErr := root.PersistentFlags().GetString("format"); getErr != nil || format != "json" {
|
||||
t.Fatalf("format = %q, %v; want primed json presentation", format, getErr)
|
||||
}
|
||||
|
||||
validRoot := &cobra.Command{Use: "dws"}
|
||||
validChat := &cobra.Command{Use: "chat"}
|
||||
validChat.AddCommand(&cobra.Command{Use: "+chat-messages", Run: func(*cobra.Command, []string) {}})
|
||||
validRoot.AddCommand(validChat)
|
||||
if ctx, err := RunPreParseArgs(validRoot, nil, []string{"chat", "+chat-messages"}); ctx != nil || err != nil {
|
||||
t.Fatalf("valid shortcut = %#v, %v", ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandSuggestionHintIsBoundedAndFallsBack(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
group := &cobra.Command{Use: "demo", SuggestionsMinimumDistance: 100}
|
||||
root.AddCommand(group)
|
||||
for _, name := range []string{"alpha", "beta", "delta", "gamma"} {
|
||||
group.AddCommand(&cobra.Command{Use: name, Run: func(*cobra.Command, []string) {}})
|
||||
}
|
||||
hint := commandSuggestionHint(group, "missing", "fallback")
|
||||
if strings.Count(hint, `"dws demo `) != maxCommandSuggestions || strings.Contains(hint, "gamma") {
|
||||
t.Fatalf("bounded hint = %q", hint)
|
||||
}
|
||||
|
||||
empty := &cobra.Command{Use: "empty"}
|
||||
root.AddCommand(empty)
|
||||
if got := commandSuggestionHint(empty, "missing", "fallback"); got != "fallback" {
|
||||
t.Fatalf("fallback hint = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandResolutionDefensiveAndExplicitSuggestionPaths(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
service := &cobra.Command{Use: "demo"}
|
||||
group := &cobra.Command{Use: "nested"}
|
||||
service.AddCommand(group)
|
||||
root.AddCommand(service)
|
||||
|
||||
if isExplicitShortcutCandidate(root, "+missing") {
|
||||
t.Fatal("root command was treated as a shortcut service")
|
||||
}
|
||||
if isExplicitShortcutCandidate(group, "+missing") {
|
||||
t.Fatal("nested command was treated as a top-level shortcut service")
|
||||
}
|
||||
if got := commandSuggestions(nil, "missing"); got != nil {
|
||||
t.Fatalf("commandSuggestions(nil) = %#v", got)
|
||||
}
|
||||
|
||||
service.SuggestionsMinimumDistance = 1
|
||||
service.AddCommand(&cobra.Command{
|
||||
Use: "canonical",
|
||||
SuggestFor: []string{"invented"},
|
||||
Run: func(*cobra.Command, []string) {},
|
||||
})
|
||||
if got := commandSuggestions(service, "invented"); len(got) != 1 || got[0] != "canonical" {
|
||||
t.Fatalf("explicit SuggestFor suggestions = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func requireCommandResolutionError(t *testing.T, err error, reason string) *apperrors.Error {
|
||||
t.Helper()
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) {
|
||||
t.Fatalf("error = %T %v, want *errors.Error", err, err)
|
||||
}
|
||||
if structured.Category != apperrors.CategoryValidation || structured.Reason != reason || structured.ExitCode() != 3 {
|
||||
t.Fatalf("structured error = %#v", structured)
|
||||
}
|
||||
return structured
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user