Compare commits
475
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
137151b38c | ||
|
|
b8216380de | ||
|
|
83bc213b7f | ||
|
|
804b9a3142 | ||
|
|
cfdb0d0556 | ||
|
|
d8686122ab | ||
|
|
222a0230ae | ||
|
|
f7befc7943 | ||
|
|
1dc924af1b | ||
|
|
0e8d6e00cf | ||
|
|
0028ed1570 | ||
|
|
8b5d9a59b0 | ||
|
|
01d663f597 | ||
|
|
2bf314c625 | ||
|
|
8c98d1abe4 | ||
|
|
5e4a65513b | ||
|
|
4ae0e0ffc3 | ||
|
|
1c9a977d08 | ||
|
|
78fabec4bb | ||
|
|
6d6404993a | ||
|
|
008d50bb3d | ||
|
|
58e8e35948 | ||
|
|
a8b0d8895b | ||
|
|
fcfead71cb | ||
|
|
0beb1c6b0c | ||
|
|
3b38d4c8da | ||
|
|
d68e340a5b | ||
|
|
98799effba | ||
|
|
9239f9070a | ||
|
|
202c5ce697 | ||
|
|
8ab2ac5e7c | ||
|
|
b85a342e9f | ||
|
|
ad72cf4b3d | ||
|
|
89154b3952 | ||
|
|
b01febf52e | ||
|
|
74b7690cbb | ||
|
|
8312c4f30e | ||
|
|
35c6fd95e1 | ||
|
|
564ff8563f | ||
|
|
f7229091ae | ||
|
|
77aa813467 | ||
|
|
1f595571c0 | ||
|
|
cd90d1c322 | ||
|
|
49ab53ea0d | ||
|
|
78433198fb | ||
|
|
4863152a4a | ||
|
|
2057fec3b0 | ||
|
|
1308d08862 | ||
|
|
8b56e9bc9e | ||
|
|
87e141f2de | ||
|
|
9b521f0392 | ||
|
|
4dcd528bc1 | ||
|
|
0bbb3a9d32 | ||
|
|
0ebd840ba9 | ||
|
|
9d356cd664 | ||
|
|
b00f43ee06 | ||
|
|
23167ef974 | ||
|
|
8b003aef16 | ||
|
|
11934eed05 | ||
|
|
23e1085a37 | ||
|
|
a352615e77 | ||
|
|
d210da501a | ||
|
|
6288199a93 | ||
|
|
6d9781fe15 | ||
|
|
5b34ed1a7e | ||
|
|
3d9a469347 | ||
|
|
7640ba7614 | ||
|
|
c790fe3c3b | ||
|
|
4886bdb3f5 | ||
|
|
4aef07ccd3 | ||
|
|
e0c49377d6 | ||
|
|
dc37dd2c34 | ||
|
|
02aad9020a | ||
|
|
d59d1091dc | ||
|
|
60d6bfeec4 | ||
|
|
bf89acb3d2 | ||
|
|
35d6f47bd6 | ||
|
|
d24b71614a | ||
|
|
765b961f4d | ||
|
|
9ab4bd10a5 | ||
|
|
14c5bed4fc | ||
|
|
c1bd6dcf64 | ||
|
|
1c8b83ec2f | ||
|
|
bb6f470df5 | ||
|
|
01af71a5ae | ||
|
|
d4ff8a5f4f | ||
|
|
47e3c2b3c5 | ||
|
|
e8ecff586a | ||
|
|
11a9ad5d49 | ||
|
|
23940e4752 | ||
|
|
8cb0f64477 | ||
|
|
bbaf033618 | ||
|
|
57cca7ef71 | ||
|
|
2d38beb7be | ||
|
|
4372a8c5ba | ||
|
|
422dc0fde3 | ||
|
|
3d59411a1a | ||
|
|
fe66ac18a4 | ||
|
|
bda408d966 | ||
|
|
33631502c9 | ||
|
|
f2a608d146 | ||
|
|
378b9f67a2 | ||
|
|
43ba466783 | ||
|
|
5c9f738012 | ||
|
|
56c5fb35b8 | ||
|
|
b19c52f61b | ||
|
|
c0641dbf64 | ||
|
|
3b5cb3b0cb | ||
|
|
fd2ed2174f | ||
|
|
5bbaa304e3 | ||
|
|
db938778af | ||
|
|
1155b9b5c0 | ||
|
|
8fa93ef030 | ||
|
|
f4ad1a15f5 | ||
|
|
d2cbd928e2 | ||
|
|
2346dfba4e | ||
|
|
f6ad2fa01a | ||
|
|
7bc56f3dea | ||
|
|
03001eb4e0 | ||
|
|
91974ce981 | ||
|
|
c6417e3527 | ||
|
|
161687ae4c | ||
|
|
4da5a07d1d | ||
|
|
2cb83d388b | ||
|
|
ba9c0f624e | ||
|
|
ff65f80c98 | ||
|
|
42240f5e9e | ||
|
|
e6b06b561d | ||
|
|
11cbc30a10 | ||
|
|
60a474f30c | ||
|
|
6e8fec5684 | ||
|
|
470aa42d7b | ||
|
|
a74d96bb96 | ||
|
|
9798a60728 | ||
|
|
e028d443d5 | ||
|
|
74859b966b | ||
|
|
76a5559ca2 | ||
|
|
c4a1018213 | ||
|
|
62d72ad84c | ||
|
|
e6fe475aea | ||
|
|
e95ac52ff4 | ||
|
|
61f8140f91 | ||
|
|
09c0cd7849 | ||
|
|
5a368a9ab8 | ||
|
|
fbcd8887ee | ||
|
|
c0838e7e41 | ||
|
|
9c6ab99bf1 | ||
|
|
87ab311764 | ||
|
|
7f4318a10d | ||
|
|
5232f632c8 | ||
|
|
615a775fdf | ||
|
|
b10da77e09 | ||
|
|
cefc5c005c | ||
|
|
15c075e6a6 | ||
|
|
f6d1e685e0 | ||
|
|
81108e150b | ||
|
|
dad9aefefa | ||
|
|
71d49cb12b | ||
|
|
f7e2efaaa2 | ||
|
|
557208e16b | ||
|
|
53401dbb0c | ||
|
|
6c52ac37dd | ||
|
|
95a17a3ffc | ||
|
|
3318741508 | ||
|
|
3d7ab2690c | ||
|
|
17eefcd24b | ||
|
|
6f62ce7997 | ||
|
|
2228a32d1a | ||
|
|
a6f79e951b | ||
|
|
096dfd48f0 | ||
|
|
3922970bfc | ||
|
|
9b0441ca56 | ||
|
|
2ab0edd5c6 | ||
|
|
4b3272bcd4 | ||
|
|
b6eaf3c5af | ||
|
|
80d5d24637 | ||
|
|
e40397e239 | ||
|
|
15bc7fdc3f | ||
|
|
da049be58d | ||
|
|
6ec64e8a03 | ||
|
|
bca56cbba6 | ||
|
|
aa4ae9a903 | ||
|
|
7a019c6fa3 | ||
|
|
103b05413e | ||
|
|
bb48aa0cc8 | ||
|
|
6ffb4bcb93 | ||
|
|
e2e4d6fc22 | ||
|
|
2c0d6e4118 | ||
|
|
169bbe88c0 | ||
|
|
08595594d7 | ||
|
|
379f625ca9 | ||
|
|
30782020ad | ||
|
|
540bbac35b | ||
|
|
9d27313f5e | ||
|
|
cc86d1e958 | ||
|
|
5619cb150e | ||
|
|
c4d5595ca9 | ||
|
|
5aaf2efb59 | ||
|
|
d583247935 | ||
|
|
dfc3b028d7 | ||
|
|
95da8214a1 | ||
|
|
d8a3d5d6fd | ||
|
|
86d1eb8030 | ||
|
|
5ed7c3adce | ||
|
|
d1f1ab724b | ||
|
|
ab529e5ee5 | ||
|
|
15cb1f4311 | ||
|
|
67505c6c83 | ||
|
|
97ca00868f | ||
|
|
61c39efb85 | ||
|
|
5b412ac196 | ||
|
|
c0e579fe6b | ||
|
|
c2ff4ab242 | ||
|
|
3fa85d19c9 | ||
|
|
df8885c350 | ||
|
|
33b76400bf | ||
|
|
2b417e2f2a | ||
|
|
c0e1ec576a | ||
|
|
d87cdef00b | ||
|
|
2b3f482fdc | ||
|
|
f79c066806 | ||
|
|
95645e4f2c | ||
|
|
4e27a3a84a | ||
|
|
575303a5b0 | ||
|
|
d9b728f8e5 | ||
|
|
8685464c53 | ||
|
|
6240584ae2 | ||
|
|
cb46823280 | ||
|
|
fdcaa61587 | ||
|
|
a0495c169b | ||
|
|
3e481d296c | ||
|
|
22f87296cd | ||
|
|
26b5939f9f | ||
|
|
c198d8577d | ||
|
|
33828b7858 | ||
|
|
0c80aa79e5 | ||
|
|
da62d11b35 | ||
|
|
a5d7fd05f1 | ||
|
|
cf13026f48 | ||
|
|
95bcace6bd | ||
|
|
20c901d7ae | ||
|
|
7a858b9732 | ||
|
|
00c337c438 | ||
|
|
27aca3ccc3 | ||
|
|
84036678dd | ||
|
|
d5031a89f0 | ||
|
|
e51ecc04f1 | ||
|
|
ce57cdf260 | ||
|
|
17741851f5 | ||
|
|
ed1ebe5d03 | ||
|
|
999e7a7b9d | ||
|
|
d10446bea7 | ||
|
|
94cee4388e | ||
|
|
3ec138ba99 | ||
|
|
dcc7e72ec1 | ||
|
|
0ed05a2c5d | ||
|
|
ae1edefee6 | ||
|
|
6dbc7ed82b | ||
|
|
0d22a4a1bd | ||
|
|
586ad0a5df | ||
|
|
83f3b4f385 | ||
|
|
6d88c9968e | ||
|
|
36c61fd8ac | ||
|
|
272b6b8a70 | ||
|
|
c3328411c9 | ||
|
|
83cfd10416 | ||
|
|
9d43a12e08 | ||
|
|
7900e27946 | ||
|
|
42f54832b0 | ||
|
|
e217901a6b | ||
|
|
5f6ca90821 | ||
|
|
cad437aabd | ||
|
|
47d71375f6 | ||
|
|
69540c3372 | ||
|
|
3a2b738c06 | ||
|
|
725e60f07c | ||
|
|
8b4453adf9 | ||
|
|
f419c0f96d | ||
|
|
13d0ae66a6 | ||
|
|
63854705fd | ||
|
|
f3b0fcdc4c | ||
|
|
109a2891de | ||
|
|
4e106cd5ad | ||
|
|
17101a8901 | ||
|
|
9858844158 | ||
|
|
00ca448aa2 | ||
|
|
afe01d4b70 | ||
|
|
4c6db326f5 | ||
|
|
f10d552fd7 | ||
|
|
66aa00fb50 | ||
|
|
8b8756b00e | ||
|
|
ec59cf8065 | ||
|
|
2ffddbd5a0 | ||
|
|
843edd700d | ||
|
|
58ff0248b6 | ||
|
|
9d6e151a6f | ||
|
|
1d3c56f9fa | ||
|
|
502317db68 | ||
|
|
0df41d3eff | ||
|
|
ab0d1d2ad6 | ||
|
|
6c895c23ed | ||
|
|
2a1ed8cc7a | ||
|
|
6e3f528f48 | ||
|
|
d70e6b85b6 | ||
|
|
358e1ab065 | ||
|
|
510b120630 | ||
|
|
f253f865c7 | ||
|
|
c8f83533fb | ||
|
|
8e34134dbc | ||
|
|
65885dd262 | ||
|
|
c5951a10ff | ||
|
|
b354b371c9 | ||
|
|
15d289d3f3 | ||
|
|
3dbd29ab50 | ||
|
|
1b50c7a5b4 | ||
|
|
57b5845eb3 | ||
|
|
75f08da197 | ||
|
|
fab84af434 | ||
|
|
2dd724f1e1 | ||
|
|
cbd70d1b88 | ||
|
|
3ac9b83565 | ||
|
|
0ae8949d40 | ||
|
|
234253e75f | ||
|
|
6a4803d85a | ||
|
|
0975d970d1 | ||
|
|
7808673431 | ||
|
|
d11e69fbdb | ||
|
|
a3566f39c4 | ||
|
|
a192e988c4 | ||
|
|
50ed921ca1 | ||
|
|
b34c29ec35 | ||
|
|
91d2e29925 | ||
|
|
26638cbd98 | ||
|
|
2f05649277 | ||
|
|
6c8e7e082b | ||
|
|
63a6de7b49 | ||
|
|
46b641f227 | ||
|
|
97e5ded043 | ||
|
|
71da2dfded | ||
|
|
21395ed12d | ||
|
|
5f2344d16d | ||
|
|
7ceeafbae8 | ||
|
|
e79efc70af | ||
|
|
54b4a24a14 | ||
|
|
7fa4ee7bac | ||
|
|
5b9234d8fe | ||
|
|
619319517a | ||
|
|
74513bae2f | ||
|
|
fcbddb0904 | ||
|
|
e1bfb343f4 | ||
|
|
7da423bf3c | ||
|
|
e84743615f | ||
|
|
fa83ee579c | ||
|
|
a102447eb5 | ||
|
|
2b131a1031 | ||
|
|
25c694aa2a | ||
|
|
e064d394ba | ||
|
|
5fdaea5f36 | ||
|
|
e8a320a06b | ||
|
|
59268a42a6 | ||
|
|
06661af43f | ||
|
|
951dd27f0c | ||
|
|
bc5e5db7ef | ||
|
|
a0accff258 | ||
|
|
f7a3e606f7 | ||
|
|
1d1aca5fd1 | ||
|
|
b199fd29cb | ||
|
|
93703113cb | ||
|
|
e5ed9e6e39 | ||
|
|
7924e84fb6 | ||
|
|
b4129c467d | ||
|
|
a12abdfb54 | ||
|
|
d9283b9a82 | ||
|
|
f1d40e26e1 | ||
|
|
0db91cfc44 | ||
|
|
f88be7ae21 | ||
|
|
e3313095ba | ||
|
|
c7882d7f72 | ||
|
|
92196738d3 | ||
|
|
0a4da58d8f | ||
|
|
f14332f143 | ||
|
|
62883b7940 | ||
|
|
6e20bc765f | ||
|
|
ecaefb416f | ||
|
|
f16feed896 | ||
|
|
d0a9dad079 | ||
|
|
e6c54cf777 | ||
|
|
7a97354d93 | ||
|
|
a46958c788 | ||
|
|
b664ace2f2 | ||
|
|
a789a2eea7 | ||
|
|
e4a1feccf0 | ||
|
|
3f39a9ddb1 | ||
|
|
b080b6e7c5 | ||
|
|
dc180f6d07 | ||
|
|
7b64576ea1 | ||
|
|
437dd234b2 | ||
|
|
04f78bcb15 | ||
|
|
9abcdb4deb | ||
|
|
c0da89e674 | ||
|
|
09fc5d993d | ||
|
|
8f3a9e9d4a | ||
|
|
567ea5d77c | ||
|
|
fc18f8fd04 | ||
|
|
a39ad4e6af | ||
|
|
301429e3aa | ||
|
|
0af5751d75 | ||
|
|
79f7ee80e0 | ||
|
|
44d640bbae | ||
|
|
06b4f3ba31 | ||
|
|
9f983be1ac | ||
|
|
80bca147e0 | ||
|
|
2cea069f55 | ||
|
|
208a6c0273 | ||
|
|
4e8469a175 | ||
|
|
b7a07abcb1 | ||
|
|
2292a49c6a | ||
|
|
cf43cf1b47 | ||
|
|
344104268a | ||
|
|
89027aa2e2 | ||
|
|
e45608bb13 | ||
|
|
ff6e2347f6 | ||
|
|
2d29f6601b | ||
|
|
fa887ccd26 | ||
|
|
30202e2b81 | ||
|
|
1203409185 | ||
|
|
0ba55350d8 | ||
|
|
14c2569cfb | ||
|
|
ae1565c0ff | ||
|
|
7581955892 | ||
|
|
24bbdda423 | ||
|
|
276658590b | ||
|
|
16d20b8178 | ||
|
|
dd89c67f4d | ||
|
|
75bf44b7be | ||
|
|
1bae872341 | ||
|
|
d1ecdcd551 | ||
|
|
a47740ca1c | ||
|
|
4ad321557f | ||
|
|
34dee96833 | ||
|
|
fc455f800c | ||
|
|
3556d28fdd | ||
|
|
618eb842a2 | ||
|
|
465acf1406 | ||
|
|
ce73a5b452 | ||
|
|
175b51cec0 | ||
|
|
53a71b08c7 | ||
|
|
3ef735bb3c | ||
|
|
44e18a4062 | ||
|
|
ccbacf84b3 | ||
|
|
7cfaa1ca74 | ||
|
|
d8f8f29062 | ||
|
|
f8af8dc1dc | ||
|
|
bb6fd2f256 | ||
|
|
580c4d201b | ||
|
|
4f754133a5 | ||
|
|
37cd629335 | ||
|
|
b447aac84b | ||
|
|
9703a21a2d | ||
|
|
0c0e2b3ce1 | ||
|
|
7d16c9693f | ||
|
|
1dee02d900 | ||
|
|
7664f04fda | ||
|
|
8177a06296 | ||
|
|
c674366aea | ||
|
|
ce5815a606 | ||
|
|
d211b79a80 | ||
|
|
de8df0fa6f | ||
|
|
9ff31cdd55 | ||
|
|
fde37f7896 | ||
|
|
31902a987e | ||
|
|
5a001f33b6 | ||
|
|
5ef52503ae | ||
|
|
8ee9fc3f48 | ||
|
|
19e19bcd2b |
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
|
||||
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
|
||||
+3
-1
@@ -25,7 +25,9 @@ category: Added
|
||||
|
||||
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
|
||||
将未归档 fragments 汇总为唯一的版本章节,并移动到
|
||||
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
|
||||
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
|
||||
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
|
||||
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
|
||||
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
|
||||
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
|
||||
删除或重写已归档文件。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc public-link and historical-version reads** — `dws doc read` forwards
|
||||
the reviewed `password` (internet-public documents with password protection)
|
||||
and `historyVersion` (read content as of a listed historical version; `0`
|
||||
denotes the document's initial version) parameters on the markdown, JSONML,
|
||||
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
|
||||
`--password` and `--version` with the same `historyVersion` forwarding, while
|
||||
`--revision` stays rejected with explicit guidance: revision is the document
|
||||
edit revision returned by JSONML reads for `+update --expected-revision`
|
||||
conditional writes, not a historical version number.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with
|
||||
strict response, identity, pagination, and real-data verification while
|
||||
retaining historical CLI discovery and argument compatibility for commands
|
||||
that remain unavailable to agents. Mailbox auto-resolution now accepts both
|
||||
reviewed string and object response shapes, and Attendance date ranges cover
|
||||
the complete requested end date without dropping cross-midnight punches whose
|
||||
actual check time is inside the requested range. The schedule query remains
|
||||
CLI-compatible but is withheld from the Agent catalog because its downstream
|
||||
service returns a successful process exit with a null body for both populated
|
||||
and empty ranges.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. A degraded child-move that cannot consume the emptied source shell fails the publication loudly with the destination retained, so a failed move never silently discards staging data. When the fresh mkdir-claim identity captured by the child-move fallback — dev:ino on POSIX, the volume file ID on Windows — no longer matches the destination, the publication reports `ErrSkillPathPublicationUncertain` and keeps the destination: the object may belong to a concurrent writer, and the mono/multi upgrade copy fallbacks as well as `dws skill setup` honor the sentinel by surfacing the state instead of retrying over (and displacing) it. npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation follow the same occupy-then-confirm contract. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Go identity proofs report a stable file identity on every platform — dev:ino on Linux and macOS, the volume file ID on Windows — with the post-publish content fingerprint as the backstop against inode recycling; Shell copied-set rollback proves dest first with inode, child names, and a recursive content digest (sorted paths, mode bits, file hashes, link targets) for the same reason, so an in-place content edit after publish is preserved rather than retracted as this transaction's object. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems — except when the failure reports the uncertain sentinel, which retains the destination for manual inspection instead of retrying over it. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible; stamp roots created before the ownership marker existed are preserved rather than pruned. Standalone installers verify every downloaded release asset against `checksums.txt`, and the Line truncated
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending.
|
||||
- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
|
||||
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
|
||||
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
|
||||
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
|
||||
business code, `dws` now retries once with the still-valid token mirrored in the same
|
||||
organization's slot (same corp, matching or backfilled user identity) before giving up,
|
||||
and writes the rotated credential back to both the identity and the organization slots so
|
||||
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
|
||||
rejections without a reviewed business code do not trigger the fallback.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
|
||||
@@ -512,6 +512,12 @@ jobs:
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
run: node .github/reviewer-routing.test.js
|
||||
|
||||
- name: Test npm installer smoke (prune, backup, publish)
|
||||
if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true'
|
||||
env:
|
||||
XDG_CONFIG_HOME: ""
|
||||
run: node test/scripts/install_js_smoke.mjs
|
||||
|
||||
test-focused:
|
||||
name: "Test (focused: ${{ matrix.shard }})"
|
||||
needs: lint
|
||||
@@ -528,7 +534,8 @@ jobs:
|
||||
# where the Schema partition alone owned most of the wall clock. The
|
||||
# app-<partition> names are pinned to the helper's partition set by
|
||||
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
|
||||
# its job silently.
|
||||
# its job silently. The CrossPlatformCoverage-heavy C range is split again
|
||||
# to retain headroom on runners reclaimed near the five-minute mark.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -536,7 +543,10 @@ jobs:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
@@ -611,7 +621,13 @@ jobs:
|
||||
|
||||
- name: Install archive tooling
|
||||
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Test shard with Race Detection
|
||||
if: ${{ steps.select.outputs.affected == 'true' }}
|
||||
@@ -666,7 +682,8 @@ jobs:
|
||||
# partitions run concurrently and each releases its framework registries
|
||||
# when the process exits; cli/smoke need headroom beyond go test -timeout for
|
||||
# setup + assembly. The app-<partition> names are pinned to the helper's
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
|
||||
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -674,7 +691,10 @@ jobs:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
@@ -751,7 +771,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Test release scripts
|
||||
shell: bash
|
||||
@@ -1129,7 +1155,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
@@ -1180,7 +1212,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Run policy and shortcut coverage
|
||||
run: |
|
||||
@@ -1261,7 +1299,13 @@ jobs:
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
if: steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -14,9 +14,12 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const webhook = process.env.DINGTALK_WEBHOOK;
|
||||
if (!webhook) {
|
||||
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
|
||||
const webhooks = [
|
||||
process.env.DINGTALK_WEBHOOK,
|
||||
process.env.DINGTALK_WEBHOOK_SECONDARY
|
||||
].filter(Boolean);
|
||||
if (webhooks.length === 0) {
|
||||
console.log('⚠️ No DingTalk webhook configured, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,12 +42,15 @@ jobs:
|
||||
}
|
||||
};
|
||||
|
||||
await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
await Promise.all(webhooks.map(webhook =>
|
||||
fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
})
|
||||
));
|
||||
|
||||
console.log('✅ DingTalk notification sent');
|
||||
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
|
||||
env:
|
||||
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
|
||||
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
|
||||
@@ -2698,9 +2698,11 @@ jobs:
|
||||
;;
|
||||
compatibility)
|
||||
test -n "$PREVIOUS_STABLE"
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
|
||||
--repo-root "$GITHUB_WORKSPACE" \
|
||||
--base-ref HEAD \
|
||||
--stable-ref "$PREVIOUS_STABLE"
|
||||
--stable-ref "$PREVIOUS_STABLE" \
|
||||
--candidate-ref HEAD
|
||||
;;
|
||||
e2e)
|
||||
bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
@@ -6,6 +6,87 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.60-beta.1] - 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
|
||||
|
||||
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
|
||||
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
|
||||
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
|
||||
|
||||
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
|
||||
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
|
||||
business code, `dws` now retries once with the still-valid token mirrored in the same
|
||||
organization's slot (same corp, matching or backfilled user identity) before giving up,
|
||||
and writes the rotated credential back to both the identity and the organization slots so
|
||||
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
|
||||
rejections without a reviewed business code do not trigger the fallback.
|
||||
|
||||
|
||||
## [1.0.59] - 2026-08-20
|
||||
|
||||
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat personal emotions** — adds commands to list, send, and favorite the current user's personal favorite emotions.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage.
|
||||
|
||||
- **Shortcut functional workflows** — fixes Drive preview accuracy, AITable write verification and deletion accounting, Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
## [1.0.59-beta.5] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
|
||||
## [1.0.59-beta.4] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
|
||||
|
||||
- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with
|
||||
strict response, identity, pagination, and real-data verification while
|
||||
retaining historical CLI discovery and argument compatibility for commands
|
||||
that remain unavailable to agents. Mailbox auto-resolution now accepts both
|
||||
reviewed string and object response shapes, and Attendance date ranges cover
|
||||
the complete requested end date without dropping cross-midnight punches whose
|
||||
actual check time is inside the requested range. The schedule query remains
|
||||
CLI-compatible but is withheld from the Agent catalog because its downstream
|
||||
service returns a successful process exit with a null body for both populated
|
||||
and empty ranges.
|
||||
|
||||
- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility.
|
||||
|
||||
- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. A degraded child-move that cannot consume the emptied source shell fails the publication loudly with the destination retained, so a failed move never silently discards staging data. When the fresh mkdir-claim identity captured by the child-move fallback — dev:ino on POSIX, the volume file ID on Windows — no longer matches the destination, the publication reports `ErrSkillPathPublicationUncertain` and keeps the destination: the object may belong to a concurrent writer, and the mono/multi upgrade copy fallbacks as well as `dws skill setup` honor the sentinel by surfacing the state instead of retrying over (and displacing) it. npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation follow the same occupy-then-confirm contract. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Go identity proofs report a stable file identity on every platform — dev:ino on Linux and macOS, the volume file ID on Windows — with the post-publish content fingerprint as the backstop against inode recycling; Shell copied-set rollback proves dest first with inode, child names, and a recursive content digest (sorted paths, mode bits, file hashes, link targets) for the same reason, so an in-place content edit after publish is preserved rather than retracted as this transaction's object. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems — except when the failure reports the uncertain sentinel, which retains the destination for manual inspection instead of retrying over it. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible; stamp roots created before the ownership marker existed are preserved rather than pruned. Standalone installers verify every downloaded release asset against `checksums.txt`, and the Line truncated
|
||||
|
||||
- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending.
|
||||
- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery.
|
||||
|
||||
|
||||
## [1.0.59-beta.3] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
+2
-2
@@ -74,9 +74,9 @@ coverage is additionally selected for platform-sensitive code.
|
||||
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
|
||||
The Make target delegates to the authoritative wrapper; CI does not invoke a
|
||||
second comparator or the legacy fixture checker. See
|
||||
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
[CLI Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
for the reviewed two-stage `pending` → `consumed` lifecycle.
|
||||
Agent-visible flag migrations must also run
|
||||
Agent-visible flag or command-path migrations must also run
|
||||
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
|
||||
it consumes the same base-owned ledger rather than a second exception list.
|
||||
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
|
||||
|
||||
@@ -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.59-beta.3"
|
||||
version "1.0.60-beta.1"
|
||||
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.59-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
|
||||
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.1/dws-skills.zip"
|
||||
sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87"
|
||||
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.58"
|
||||
version "1.0.59"
|
||||
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.58/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz"
|
||||
sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
|
||||
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz"
|
||||
sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
|
||||
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz"
|
||||
sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
|
||||
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz"
|
||||
sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
|
||||
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
|
||||
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
|
||||
The upgrade process follows a two-phase atomic flow to ensure consistency:
|
||||
|
||||
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice.
|
||||
|
||||
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
|
||||
|
||||
@@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
|
||||
> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
@@ -482,7 +482,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
@@ -530,12 +530,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# Listen for all six public OA approval events in one process
|
||||
# Listen for all seven public OA approval events in one process
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
+4
-3
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
@@ -476,7 +476,7 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
@@ -524,12 +524,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# 一个进程监听全部六个公开 OA 审批事件
|
||||
# 一个进程监听全部七个公开 OA 审批事件
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
+63
-5
@@ -13,13 +13,71 @@ if (!fs.existsSync(binaryPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
|
||||
// Interactive commands must remain in the terminal's foreground session so
|
||||
// prompts can use /dev/tty. Non-interactive launches use a separate process
|
||||
// group, allowing a signal sent only to this wrapper to reach the full vendor
|
||||
// process tree exactly once.
|
||||
const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
|
||||
|
||||
const child = childProcess.spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
detached: isolateVendorProcessGroup,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(1);
|
||||
let spawnFailed = false;
|
||||
let forwardedSignal = null;
|
||||
const forwardedSignals = ["SIGINT", "SIGTERM"];
|
||||
|
||||
function forwardSignal(signal) {
|
||||
forwardedSignal = signal;
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
if (process.platform === "win32") {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
if (!isolateVendorProcessGroup) {
|
||||
// Ctrl-C is generated for the whole foreground process group, including
|
||||
// the vendor. SIGTERM is not terminal-generated and still needs an
|
||||
// explicit handoff when a process manager targets only this wrapper.
|
||||
if (signal === "SIGTERM") {
|
||||
child.kill(signal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// detached makes the vendor PID the leader of its POSIX process group.
|
||||
// Signal the whole group so any subprocesses inherit the same shutdown.
|
||||
process.kill(-child.pid, signal);
|
||||
} catch (error) {
|
||||
// The group may have completed between the state check and kill.
|
||||
if (error.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
const signalHandlers = new Map(
|
||||
forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]),
|
||||
);
|
||||
for (const signal of forwardedSignals) {
|
||||
process.on(signal, signalHandlers.get(signal));
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
spawnFailed = true;
|
||||
console.error(error.message);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
for (const forwarded of forwardedSignals) {
|
||||
process.removeListener(forwarded, signalHandlers.get(forwarded));
|
||||
}
|
||||
|
||||
const exitSignal = forwardedSignal || signal;
|
||||
if (exitSignal && process.platform !== "win32") {
|
||||
process.kill(process.pid, exitSignal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = spawnFailed || code === null ? 1 : code;
|
||||
});
|
||||
+1214
-126
File diff suppressed because it is too large.
Load diff
@@ -32,6 +32,6 @@
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": ">=16.7.0"
|
||||
}
|
||||
}
|
||||
@@ -157,6 +157,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
"",
|
||||
"candidate flag migration manifest",
|
||||
)
|
||||
approvedCommandMigrationsPath := flags.String(
|
||||
"approved-command-migrations",
|
||||
"",
|
||||
"merge-base-owned approved command migration manifest",
|
||||
)
|
||||
candidateCommandMigrationsPath := flags.String(
|
||||
"candidate-command-migrations",
|
||||
"",
|
||||
"candidate command migration manifest",
|
||||
)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -174,8 +184,13 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
|
||||
if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") {
|
||||
return false, fmt.Errorf(
|
||||
"--approved-command-migrations and --candidate-command-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("migration compare requires both --base and --stable")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
@@ -197,7 +212,39 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
if *approvedMigrationsPath != "" {
|
||||
if *approvedCommandMigrationsPath != "" {
|
||||
flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}}
|
||||
flagCandidate := flagApproved
|
||||
if *approvedMigrationsPath != "" {
|
||||
flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", err)
|
||||
}
|
||||
flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read candidate flag migrations: %w", err)
|
||||
}
|
||||
}
|
||||
commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved command migrations: %w", readErr)
|
||||
}
|
||||
commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read candidate command migrations: %w", readErr)
|
||||
}
|
||||
report, err = interfacesnapshot.CompareAllWithInterfaceMigrations(
|
||||
current,
|
||||
references,
|
||||
flagApproved,
|
||||
flagCandidate,
|
||||
commandApproved,
|
||||
commandCandidate,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validate interface migration lifecycle: %w", err)
|
||||
}
|
||||
} else if *approvedMigrationsPath != "" {
|
||||
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
|
||||
@@ -234,6 +281,15 @@ func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationMani
|
||||
return interfacesnapshot.ReadFlagMigrationManifest(file)
|
||||
}
|
||||
|
||||
func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.CommandMigrationManifest{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.ReadCommandMigrationManifest(file)
|
||||
}
|
||||
|
||||
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
|
||||
for _, command := range snapshot.Commands {
|
||||
path := strings.TrimPrefix(command.Path, "dws")
|
||||
@@ -280,5 +336,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]")
|
||||
}
|
||||
@@ -166,6 +166,102 @@ func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareCommandMigrationInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
emptyFlag := writeManifest(t, dir, "empty-flags.json", `{"version":1,"migrations":[]}`)
|
||||
emptyCommand := writeManifest(t, dir, "empty-commands.json", `{"version":1,"migrations":[]}`)
|
||||
invalid := writeManifest(t, dir, "invalid-commands.json", `{`)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
args := []string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
"--stable", snapshotPath,
|
||||
"--approved-flag-migrations", emptyFlag,
|
||||
"--candidate-flag-migrations", emptyFlag,
|
||||
"--approved-command-migrations", emptyCommand,
|
||||
"--candidate-command-migrations", emptyCommand,
|
||||
}
|
||||
if exitCode := run(args, &stdout, &stderr); exitCode != 0 {
|
||||
t.Fatalf("combined migration compare exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{"approved flag", invalid, emptyFlag, "read approved flag migrations"},
|
||||
{"candidate flag", emptyFlag, invalid, "read candidate flag migrations"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
testArgs := []string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
|
||||
"--approved-flag-migrations", test.approved,
|
||||
"--candidate-flag-migrations", test.candidate,
|
||||
"--approved-command-migrations", emptyCommand,
|
||||
"--candidate-command-migrations", emptyCommand,
|
||||
}
|
||||
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("combined flag error exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{"approved", invalid, emptyCommand, "read approved command migrations"},
|
||||
{"candidate", emptyCommand, invalid, "read candidate command migrations"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
testArgs := []string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
|
||||
"--approved-command-migrations", test.approved,
|
||||
"--candidate-command-migrations", test.candidate,
|
||||
}
|
||||
if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("command manifest error exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
stderr.Reset()
|
||||
if exitCode := run([]string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath,
|
||||
"--approved-command-migrations", emptyCommand,
|
||||
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "provided together") {
|
||||
t.Fatalf("one-sided command manifest exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
|
||||
if _, err := readCommandMigrationManifest(filepath.Join(dir, "missing.json")); err == nil {
|
||||
t.Fatal("missing command migration manifest unexpectedly read")
|
||||
}
|
||||
if _, err := readCommandMigrationManifest(invalid); err == nil {
|
||||
t.Fatal("invalid command migration manifest unexpectedly read")
|
||||
}
|
||||
|
||||
pending := writeManifest(t, dir, "pending-command.json", commandMigrationManifestJSON("pending"))
|
||||
consumed := writeManifest(t, dir, "consumed-command.json", commandMigrationManifestJSON("consumed"))
|
||||
stderr.Reset()
|
||||
if exitCode := run([]string{
|
||||
"compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath,
|
||||
"--approved-command-migrations", pending,
|
||||
"--candidate-command-migrations", consumed,
|
||||
}, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "validate interface migration lifecycle") {
|
||||
t.Fatalf("command lifecycle error exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
@@ -567,6 +663,33 @@ func flagMigrationManifestJSON(state string) string {
|
||||
}`, "STATE", state, 1)
|
||||
}
|
||||
|
||||
func commandMigrationManifestJSON(state string) string {
|
||||
return strings.Replace(`{
|
||||
"version": 1,
|
||||
"migrations": [{
|
||||
"kind": "command_move",
|
||||
"legacy": {
|
||||
"command": "dws chat message old",
|
||||
"before": {"present": true, "runnable": true},
|
||||
"after": {"present": true, "runnable": true, "hidden": true}
|
||||
},
|
||||
"replacement": {
|
||||
"command": "dws chat topic new",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "runnable": true}
|
||||
},
|
||||
"schema": {
|
||||
"product_id": "chat",
|
||||
"source_tool_id": "chat.move",
|
||||
"replacement_tool_id": "chat.move",
|
||||
"parameters": []
|
||||
},
|
||||
"state": "STATE",
|
||||
"reason": "reviewed command migration"
|
||||
}]
|
||||
}`, "STATE", state, 1)
|
||||
}
|
||||
|
||||
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name && flag.Type == flagType {
|
||||
|
||||
+5
-5
@@ -184,11 +184,11 @@ candidate SHA。
|
||||
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
|
||||
flag 迁移。
|
||||
|
||||
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
|
||||
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
Schema compatibility 使用同一组 base、stable、candidate refs,以及 base-owned flag
|
||||
与 command migration ledgers。merge-base-owned checker 分别规范化 merge-base 与
|
||||
stable 的完整 Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过
|
||||
Interface lifecycle 的 exact rename、command move 或 flag extraction 规范化到当前历史
|
||||
副本,不会维护第二份 allowlist,也不会放宽其他 Schema 历史字段。
|
||||
|
||||
For a release-seal branch that archives rendered fragments:
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
# CLI flag 兼容迁移治理
|
||||
# CLI Help / Schema 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
本文定义两种受控 flag 迁移:
|
||||
|
||||
1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。
|
||||
2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。
|
||||
|
||||
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
|
||||
|
||||
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
|
||||
|
||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||
@@ -31,7 +38,7 @@ Smoke fixture,不参与迁移审批。
|
||||
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
|
||||
调用方因漏传历史参考而提前清理 consumed receipt。
|
||||
|
||||
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
|
||||
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 flag 机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空 flag 清单。后续引入 command migration 扩展时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,因此允许随治理 PR 提交仍处于 before 的 pending 计划,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
|
||||
|
||||
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
|
||||
|
||||
@@ -39,22 +46,80 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁
|
||||
|
||||
```text
|
||||
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
scripts/policy/interface-migrations/approved-command-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
|
||||
首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。
|
||||
|
||||
## 跨命令迁移原语
|
||||
|
||||
`approved-command-migrations-v1.json` 只接受两种 `kind`:
|
||||
|
||||
| kind | CLI after 状态 | Schema 允许的精确投影 |
|
||||
|---|---|---|
|
||||
| `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 |
|
||||
| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity;清单必须完整列出每个 source 参数到 replacement 参数或常量 property 的承接关系 |
|
||||
|
||||
`command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement
|
||||
不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。
|
||||
稳定 Schema tool 可以继续接受普通的 optional 参数新增,但不得借路径迁移引入清单未登记的
|
||||
`required`、`cli_required` 或 `required_when` 参数;参数改名的目标也不得与历史
|
||||
Schema 中已有的其他参数重名,避免把两个历史参数静默合并。`flag_extraction` 只接受
|
||||
optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。它必须对 source tool
|
||||
的全部历史参数逐项声明:普通参数使用精确 `from` → `to`(同名也必须显式写出),且恰好
|
||||
一个与 legacy flag 同名的 `from` 使用 `replacement_constant`,不得同时声明 `to`;所有
|
||||
`from` 与 replacement 参数/property 目标必须唯一。legacy bool flag 的 `no_opt` 必须等于
|
||||
常量布尔值的字符串形式。v1 只治理 optional bool flag 的 `NoOpt=true` 激活分支,因此
|
||||
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
|
||||
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
|
||||
|
||||
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
|
||||
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
|
||||
`state=consumed` 的 flag rename 回执作为前驱边。例如
|
||||
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
|
||||
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
|
||||
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
|
||||
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
|
||||
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
|
||||
并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
|
||||
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
|
||||
|
||||
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
|
||||
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
|
||||
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
|
||||
必须通过 `NewLeafCommand` 的 `ConstParams` 声明并实际注入
|
||||
`convThreadEnabled=true`;手写 `RunE` 固定值、Cobra annotation 或只改清单都不能提供这份
|
||||
同源证据,Snapshot 只读取 `corecmd` 包内私有注册表公开的只读副本。第一次向旧快照增加
|
||||
bool 常量证据属于 bootstrap;一旦任一历史快照已记录该
|
||||
证据,普通 Interface Compare 会持续要求 property/value 集合完全一致,因此 ledger 清理后
|
||||
删除、翻转或增加常量仍会阻塞。若 candidate 改动 command ledger,则
|
||||
`internal/corecmd/corecmd.go`、`internal/corecmd/interface_const_params.go` 与
|
||||
`internal/helpers/leaf.go` 三份执行/证据桥必须保持 base Git blob 不变;框架演进必须先用
|
||||
独立 PR 合入,不能和产品消费混在一起。
|
||||
|
||||
replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 非空时不得删除或改值;
|
||||
历史未声明时允许 replacement 新增 dry-run。这与普通 Schema 兼容规则保持同一单调边界。
|
||||
|
||||
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
|
||||
|
||||
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
|
||||
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
|
||||
rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期:
|
||||
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
|
||||
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
|
||||
|
||||
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
|
||||
|
||||
@@ -99,6 +164,27 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
|
||||
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
|
||||
|
||||
同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"kind": "requiredness_change",
|
||||
"command": "dws report entry submit",
|
||||
"flag": {
|
||||
"name": "to-user-ids",
|
||||
"before": {"present": true, "type": "string", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reject report submissions that have no visible recipient."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `alias_of` 是框架来源的受评审关系证据
|
||||
|
||||
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
|
||||
@@ -119,10 +205,11 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
|
||||
## 豁免边界
|
||||
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
|
||||
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
@@ -130,6 +217,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
|
||||
- `alias_of` 缺失、指向变化或 alias chain;
|
||||
- 命令路径及任何无关的阻塞性接口变化;
|
||||
- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移;
|
||||
- 不精确、部分完成、超出记录范围的 surface 变化。
|
||||
|
||||
## Schema 投影边界
|
||||
@@ -157,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche
|
||||
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
|
||||
验证 candidate 是否仍与该 canonical contract 兼容。
|
||||
|
||||
`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与
|
||||
`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter
|
||||
不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、
|
||||
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
|
||||
任何不相干漂移继续阻塞。
|
||||
|
||||
## 本地验证
|
||||
|
||||
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Products**: 13
|
||||
- **Total commands**: 160
|
||||
- **Total commands**: 163
|
||||
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
|
||||
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
|
||||
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
|
||||
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
|
||||
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
|
||||
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
|
||||
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
|
||||
|
||||
_Group chats, conversations, messages, and robot/webhook integrations._
|
||||
|
||||
**23 commands**
|
||||
**26 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
|
||||
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
|
||||
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
|
||||
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
|
||||
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
|
||||
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
|
||||
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
|
||||
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
|
||||
|
||||
+10
-5
@@ -23,7 +23,7 @@
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
|
||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||
|
||||
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -140,14 +140,19 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
|
||||
[`.changes/README.md`](../.changes/README.md)。预发封板时
|
||||
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
|
||||
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。如果 beta 发布后又有
|
||||
带 fragment 的 PR 合入,而维护者决定直接发布 stable,
|
||||
`scripts/release/prepare-changelog.sh stable <version> --from-beta <tag>` 会保留 beta 晋级摘要
|
||||
模板,并把这些 post-beta fragments 写到明确的 `Changes since <beta>` 边界之后,再移动到
|
||||
`.changes/released/<stable-version>/`。没有 active fragment 时,stable 仍只生成原有晋级摘要
|
||||
模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与
|
||||
归档移动,供审计复核。
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
|
||||
@@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约
|
||||
|
||||
| 模式 | Agent 目录布局 | 选择方式 |
|
||||
|---|---|---|
|
||||
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
|
||||
| mono(兼容) | `<agent-home>/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in |
|
||||
| multi(默认) | canonical `~/.agents/skills/dingtalk-*/`;非 universal Agent 使用链接或复制兼容层 | 默认;`dws skill setup --mode multi` |
|
||||
| mono(兼容) | canonical `~/.agents/skills/dws/`;非 universal Agent 使用链接或复制兼容层 | `dws skill setup --mode mono` 或安装器的 mono opt-in |
|
||||
|
||||
模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装
|
||||
mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、
|
||||
@@ -140,10 +140,26 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
|
||||
|
||||
## 8. Upgrade 与恢复语义
|
||||
|
||||
升级器对每个 Agent 目标执行:
|
||||
升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为
|
||||
universal 的 Agent 不再保留 Agent 私有副本;检测到的
|
||||
非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical
|
||||
的目录链接:npm 与 PowerShell 安装器在 Windows 上创建 junction,`dws upgrade` /
|
||||
`dws skill setup` 创建符号链接(`os.Symlink`)。链接不可用时回退为内容完整的
|
||||
直接复制,包括未开启开发者模式、因而无法创建符号链接的 Windows。
|
||||
自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、
|
||||
`GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、
|
||||
`.moltbot` 必须按 Agent 实际优先级解析。
|
||||
|
||||
- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback;
|
||||
- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill;
|
||||
Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts` 与 `installer.ts`(基准提交
|
||||
`c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个
|
||||
non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent
|
||||
解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持
|
||||
Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、
|
||||
`.amp/skills`、`.cline/skills` 与
|
||||
`.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。
|
||||
对 universal Agent,上游 installer 的 global 模式明确选择 canonical 并跳过
|
||||
Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别和退役历史 native
|
||||
路径,不作为 universal symlink 模式的发布目标。
|
||||
|
||||
1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill;
|
||||
2. 在目标文件系统的 staging 中复制完整新集合;
|
||||
@@ -151,6 +167,14 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen
|
||||
4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录;
|
||||
5. 仅在没有目标失败且至少一个目标成功时更新状态快照。
|
||||
|
||||
旧集合可能位于外部卷或自定义 Agent 根,而备份固定写入
|
||||
`~/.dws/skill-backups`。因此备份与反向恢复统一采用 rename-first:同卷直接原子
|
||||
rename;遇到跨文件系统错误时,在目标所在文件系统创建临时 staging,词法复制并
|
||||
保留目录/文件权限、普通文件、符号链接及 dangling symlink,校验路径类型、目录项、
|
||||
文件大小与 SHA256、链接目标后,再将 staging 原子 rename 为正式目标。正式目标
|
||||
再次校验成功后才删除源路径。复制、校验或发布失败时保留源并清理 staging;源删除
|
||||
失败时允许源与正式目标同时存在,但必须返回明确错误,不能报告成功。
|
||||
|
||||
Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前;
|
||||
备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同
|
||||
Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与
|
||||
@@ -159,11 +183,31 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功
|
||||
## 9. 备份合同
|
||||
|
||||
- 路径:`~/.dws/skill-backups/<UTC 时间戳>/...`;
|
||||
- 主要操作:同一文件系统内使用 rename 移动;
|
||||
- 主要操作:同一文件系统内使用 rename 移动;跨文件系统使用目标卷 staging 的
|
||||
copy → verify → publish → remove 回退;
|
||||
- 失败语义:备份失败时原目录保持不变,目标安装失败;
|
||||
- 恢复语义:反向恢复使用相同回退;若删除备份源失败,原路径和备份可同时存在,
|
||||
但恢复必须失败并明确提示两份均被保留;
|
||||
- 可见性:计划和执行日志显示原路径与备份路径;
|
||||
- 保留策略:自动修剪,仅保留最近 5 批。
|
||||
|
||||
跨卷回退只有 staging → 正式目标的发布 rename 是原子的,整次迁移不是跨文件系统
|
||||
原子事务;该边界由“发布前不删源、发布后再次校验、删除失败保留两份”补偿。Shell
|
||||
入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现
|
||||
上述验证和失败合同。
|
||||
|
||||
原子 no-replace 发布(Linux `RENAME_NOREPLACE`、Darwin `RENAME_EXCL`)依赖底层文件
|
||||
系统支持:`rename(2)` 只列出 ext4、btrfs、tmpfs 与 cifs,因此 NFS、FUSE 与
|
||||
overlayfs 家目录会以 `EINVAL` 拒绝该 flag。这些文件系统不得让安装整体失败,而是降级
|
||||
为原子占位发布:目录目标用 `mkdir` 认领(已占用即 `EEXIST`,认领期间目标始终被本事务
|
||||
持有,源子项逐个移入认领目录,最终以 rename 覆盖仅属于本事务的空认领或直接移入);
|
||||
普通文件目标用硬链接占位(同样以 `EEXIST` 拒绝已占用路径)后删除源。任何一步失败都会
|
||||
回迁已移动的子项并只撤销本事务的占位,被并发创建的对象(文件、符号链接或目录)既不会
|
||||
被覆盖,也不会被链接进内部。逐子项移动路径不是全量原子可见(降级文件系统上的可接受
|
||||
边界),但不覆盖契约在所有平台保持不变。Windows `MoveFile` 本身即拒绝已存在的目标,
|
||||
无需降级。npm 与 Shell 安装面遵循同一占位模型:目录用 `mkdir`/子项移动,链接直接在
|
||||
目标路径创建(symlink(2) 原子拒绝已占用路径)。
|
||||
|
||||
备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行
|
||||
`dws skill setup --mode mono`。
|
||||
|
||||
@@ -192,6 +236,7 @@ setup 在未显式指定 `--source` 时的本地回退缓存。
|
||||
| `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 |
|
||||
| npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 |
|
||||
| `scripts/install-event.sh` / `install-devapp.*` | 产品 multi 子集 | 同样使用 canonical 与 Agent 兼容层 |
|
||||
|
||||
Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。
|
||||
|
||||
@@ -209,6 +254,12 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行
|
||||
- 复制失败不留下 Agent 可见的残缺官方目录;
|
||||
- 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill;
|
||||
- Windows、macOS、Linux 的路径和覆盖率门禁;
|
||||
- symlinked parent、npm/PowerShell 的 Windows junction、`dws upgrade` /
|
||||
`dws skill setup` 的符号链接、链接失败复制回退与 broken link 修复;
|
||||
- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级;
|
||||
- `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的
|
||||
正向备份、反向恢复、普通链接及 dangling symlink 词法保留;
|
||||
- copy、verify、publish、remove 各阶段故障,以及非跨设备权限错误不得进入复制回退;
|
||||
- npm、Shell、PowerShell 与包管理器安装冒烟。
|
||||
|
||||
## 13. 后续演进
|
||||
|
||||
+236
-242
@@ -1,7 +1,17 @@
|
||||
{
|
||||
"generated_at": "2026-08-18T17:38:50.904696",
|
||||
"count": 436,
|
||||
"generated_at": "2026-08-21T10:07:48.251313",
|
||||
"count": 418,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aisearch",
|
||||
"command": "+search-person",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -933,137 +943,84 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+check-record",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+check-result",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-adjustment-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+get-approve-template",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-checkin-record",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-leave-records",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+get-overtime-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-schedule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-self-setting",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+get-summary",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+list-approve",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+list-leave-types",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+my-attendance",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+query-report-data",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+search-adjustment-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+search-class",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+search-group",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "attendance",
|
||||
"command": "+search-overtime-rule",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "attendance",
|
||||
"command": "+this-month",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -2316,102 +2273,134 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+by-mobile",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+dept-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-dept-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-followings",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-role-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+list-roles",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-sub-depts",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+lookup",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+me",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+org",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+resolve-dept",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+search-mobile",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+search-user",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+team",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
@@ -2547,32 +2536,14 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "ding",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"service": "ding",
|
||||
"command": "+recall-personal",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "ding",
|
||||
"command": "+receiver-status",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"service": "ding",
|
||||
"command": "+send-personal",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3305,74 +3276,84 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "mail",
|
||||
"command": "+contact-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+find-mail-user",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+folder-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+recent-mail",
|
||||
"command": "+message",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+messages",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+search-mail",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+tag-list",
|
||||
"command": "+thread",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+template-list",
|
||||
"command": "+triage",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "mail",
|
||||
"command": "+thread-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "mail",
|
||||
"command": "+unread-mail",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "mail",
|
||||
"command": "+user-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3645,81 +3626,74 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-cc",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-executed",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-forms",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-pending",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-submitted",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+my-initiated",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "oa",
|
||||
"command": "+search-forms",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+inbox-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+outbox-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+report-latest",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "sheet",
|
||||
"command": "+list-sheets",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "sheet",
|
||||
"command": "+read",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3931,6 +3905,26 @@
|
||||
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+query",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+update",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
@@ -4132,4 +4126,4 @@
|
||||
"availability": "available"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
|
||||
@@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t
|
||||
{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"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
|
||||
@@ -403,7 +403,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
|
||||
UseWhen: []string{
|
||||
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听七个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
|
||||
},
|
||||
|
||||
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount != 22 {
|
||||
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
|
||||
if publicCount != 23 {
|
||||
t.Fatalf("public personal events = %d, want 23 (16 IM + 7 OA)", publicCount)
|
||||
}
|
||||
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
|
||||
if !ruleTypes[ruleType] {
|
||||
|
||||
@@ -1295,6 +1295,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
||||
}
|
||||
|
||||
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
|
||||
hasTarget := false
|
||||
for _, id := range subscribeIDs {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
hasTarget = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTarget {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
|
||||
|
||||
@@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
oldStop := personalStopConsumers
|
||||
oldQuery := personalQueryStatus
|
||||
oldFind := personalFindProcess
|
||||
@@ -746,6 +746,18 @@ func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
personalSignalProcess = oldSignal
|
||||
}()
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
t.Fatal("targeted stop called without a subscribe_id")
|
||||
return transport.ConsumerStopResp{}, nil
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
t.Fatal("legacy status queried without a subscribe_id")
|
||||
return nil, nil
|
||||
}
|
||||
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil {
|
||||
t.Fatalf("empty target stop = %v", err)
|
||||
}
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
|
||||
}
|
||||
|
||||
@@ -61,6 +61,13 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceCC,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
@@ -161,6 +168,7 @@ func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceCC,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -414,6 +422,7 @@ func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceCC,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools)
|
||||
assertMarkdownDriveRoutesStayCrossProduct(t, root, tools)
|
||||
assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools)
|
||||
}
|
||||
|
||||
func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
registered := 0
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "markdown" {
|
||||
registered++
|
||||
}
|
||||
}
|
||||
if registered != 0 {
|
||||
t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered)
|
||||
}
|
||||
|
||||
type route struct {
|
||||
canonical string
|
||||
confirmation string
|
||||
flags []string
|
||||
}
|
||||
routes := map[string]route{
|
||||
"create": {
|
||||
canonical: "markdown.create", confirmation: "not_required",
|
||||
flags: []string{"content", "file", "folder", "name", "space-id", "workspace"},
|
||||
},
|
||||
"fetch": {
|
||||
canonical: "markdown.fetch", confirmation: "not_required",
|
||||
flags: []string{"node", "output", "space-id", "workspace"},
|
||||
},
|
||||
"overwrite": {
|
||||
canonical: "markdown.overwrite", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"},
|
||||
},
|
||||
"patch": {
|
||||
canonical: "markdown.patch", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"},
|
||||
},
|
||||
"diff": {
|
||||
canonical: "markdown.diff", confirmation: "not_required",
|
||||
flags: []string{"context", "file", "node", "version", "version2"},
|
||||
},
|
||||
}
|
||||
|
||||
group := mustFindCommand(t, root, "markdown")
|
||||
children := map[string]bool{}
|
||||
for _, child := range group.Commands() {
|
||||
children[child.Name()] = true
|
||||
}
|
||||
if len(children) != len(routes) {
|
||||
t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children)
|
||||
}
|
||||
|
||||
for name, want := range routes {
|
||||
leaf := mustFindCommand(t, root, "markdown", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !children[name] {
|
||||
t.Errorf("markdown %s is not mounted on the ordinary product group", name)
|
||||
}
|
||||
for _, flag := range want.flags {
|
||||
if leaf.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("markdown %s is missing routed flag --%s", name, flag)
|
||||
}
|
||||
}
|
||||
if shortcut.InPublicCatalog("markdown", "+"+name) {
|
||||
t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name)
|
||||
}
|
||||
|
||||
meta, ok := cli.ResolveMeta("markdown " + name)
|
||||
if !ok {
|
||||
t.Errorf("markdown %s missing from assembled Schema", name)
|
||||
continue
|
||||
}
|
||||
if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name {
|
||||
t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name)
|
||||
}
|
||||
if meta.Safety.Confirmation != want.confirmation {
|
||||
t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation)
|
||||
}
|
||||
|
||||
tool := tools[want.canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("markdown %s missing from full delivery Schema", name)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("markdown %s availability=%q, want available", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("markdown %s interface_mode=%q, want composite", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("markdown %s is missing the reviewed composite routing reason", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
driveShortcuts := map[string]string{
|
||||
"+copy": "drive.shortcut_copy",
|
||||
"+delete": "drive.shortcut_delete",
|
||||
"+find-file": "drive.shortcut_find_file",
|
||||
"+list": "drive.shortcut_list",
|
||||
"+move": "drive.shortcut_move",
|
||||
"+publish-get": "drive.shortcut_publish_get",
|
||||
"+recycle-restore": "drive.shortcut_recycle_restore",
|
||||
"+rename": "drive.shortcut_rename",
|
||||
"+version-download": "drive.shortcut_version_download",
|
||||
"+version-get": "drive.shortcut_version_get",
|
||||
"+version-history": "drive.shortcut_version_history",
|
||||
"+version-revert": "drive.shortcut_version_revert",
|
||||
}
|
||||
for name, canonical := range driveShortcuts {
|
||||
leaf := mustFindCommand(t, root, "drive", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !shortcut.InPublicCatalog("drive", name) {
|
||||
t.Errorf("drive %s is not in the public Shortcut catalog", name)
|
||||
}
|
||||
assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical)
|
||||
}
|
||||
|
||||
ordinaryRoutes := map[string]string{
|
||||
"drive permission list": "drive.list_permission",
|
||||
"drive pull": "drive.folder_pull",
|
||||
"drive push": "drive.folder_push",
|
||||
"drive status": "drive.folder_status",
|
||||
"drive sync": "drive.folder_sync",
|
||||
"wiki node list": "wiki.list_nodes",
|
||||
}
|
||||
for cliPath, canonical := range ordinaryRoutes {
|
||||
assertMarkdownCrossProductRoute(t, tools, cliPath, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) {
|
||||
t.Helper()
|
||||
meta, ok := cli.ResolveMeta(cliPath)
|
||||
if !ok {
|
||||
t.Errorf("cross-product route %q is missing from assembled Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath {
|
||||
t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical)
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("cross-product route %q availability=%q, want available", cliPath, got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
for _, canonical := range []string{
|
||||
"oa.shortcut_approve_by",
|
||||
"oa.shortcut_done_approvals",
|
||||
"oa.shortcut_list_cc",
|
||||
"oa.shortcut_list_executed",
|
||||
"oa.shortcut_list_forms",
|
||||
"oa.shortcut_list_pending",
|
||||
"oa.shortcut_list_submitted",
|
||||
"oa.shortcut_my_initiated",
|
||||
"oa.shortcut_pending",
|
||||
"oa.shortcut_search_forms",
|
||||
} {
|
||||
tool, ok := snapshot.Tools[canonical]
|
||||
if !ok {
|
||||
t.Errorf("final Schema lacks OA tool %s", canonical)
|
||||
continue
|
||||
}
|
||||
if got := tool["availability"]; got != contract.InterfaceAvailable {
|
||||
t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,66 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
return `{"success":true,"result":{"events":[],"hasMore":false,"nextCursor":""}}`
|
||||
case "get_calendar_detail":
|
||||
return c.paramAliasCalendarDetailResponse()
|
||||
case "get_calendar_participants":
|
||||
return `{"success":true,"result":{"participants":[{"userId":"fixture-user","displayName":"Fixture User"},{"userId":"user-2","displayName":"User Two"}]}}`
|
||||
case "search_calendar":
|
||||
return `{"success":true,"result":{"calendars":[]}}`
|
||||
case "search_rooms":
|
||||
return `{"success":true,"result":{"rooms":[]}}`
|
||||
case "query_available_meeting_room":
|
||||
return `{"success":true,"result":{"rooms":[],"hasMore":false}}`
|
||||
case "list_meeting_room_groups":
|
||||
return `{"success":true,"result":{"groups":[]}}`
|
||||
case "query_busy_status":
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "list_by_keyword_and_time_range":
|
||||
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
|
||||
case "get_minutes_basic_info":
|
||||
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
|
||||
case "get_minutes_transcription":
|
||||
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
|
||||
case "create_personal_todo":
|
||||
return `{"success":true,"result":{"taskId":"task-1"}}`
|
||||
case "get_todo_detail":
|
||||
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
|
||||
case "get_user_todos_in_current_org":
|
||||
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
|
||||
case "add_todo_reminder":
|
||||
return `{"success":true}`
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1"}`
|
||||
case "move_document", "add_member", "update_member", "remove_member":
|
||||
return `{"success":true}`
|
||||
case "get_document_info":
|
||||
if len(c.calls) > 1 {
|
||||
switch c.calls[len(c.calls)-2].tool {
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
|
||||
case "move_document":
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
|
||||
}
|
||||
}
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
return `{"success":true}`
|
||||
case "respond":
|
||||
status := "accepted"
|
||||
if call := c.lastParamAliasCall(); call != nil {
|
||||
if value, ok := call.args["responseStatus"].(string); ok && value != "" {
|
||||
status = value
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"responseStatus": status}})
|
||||
return string(encoded)
|
||||
case "get_current_user_profile":
|
||||
return `{"success":true,"result":{"userId":"user-1","name":"Fixture Current User"}}`
|
||||
case "query_records":
|
||||
return `{"success":true,"status":"success","error":{},"data":{}}`
|
||||
case "search_mail_users":
|
||||
@@ -127,6 +186,41 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
}
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) lastParamAliasCall() *paramAliasToolCall {
|
||||
if len(c.calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &c.calls[len(c.calls)-1]
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) paramAliasCalendarDetailResponse() string {
|
||||
event := map[string]any{
|
||||
"eventId": "event-1",
|
||||
"summary": "Fixture Meeting",
|
||||
"description": "fixture description",
|
||||
"startDateTime": "2026-03-10T09:00:00+08:00",
|
||||
"endDateTime": "2026-03-10T10:00:00+08:00",
|
||||
}
|
||||
for _, call := range c.calls {
|
||||
switch call.tool {
|
||||
case "create_calendar_event", "update_calendar_event":
|
||||
for _, key := range []string{"eventId", "summary", "description", "startDateTime", "endDateTime", "timeZone", "location", "freeBusy"} {
|
||||
if value, ok := call.args[key]; ok {
|
||||
event[key] = value
|
||||
}
|
||||
}
|
||||
case "respond":
|
||||
if value, ok := call.args["responseStatus"]; ok {
|
||||
event["responseStatus"] = value
|
||||
}
|
||||
case "delete_calendar_event":
|
||||
event["status"] = "cancelled"
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": event})
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func (*paramAliasCaptureCaller) Format() string { return "json" }
|
||||
func (*paramAliasCaptureCaller) DryRun() bool { return false }
|
||||
func (*paramAliasCaptureCaller) Fields() string { return "" }
|
||||
|
||||
@@ -5,6 +5,8 @@ package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -17,6 +19,8 @@ import (
|
||||
const (
|
||||
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
|
||||
paramAliasCalendarPayloadChildEnv = "DWS_TEST_CALENDAR_PARAM_ALIAS_PAYLOAD_CHILD"
|
||||
)
|
||||
|
||||
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
|
||||
@@ -46,7 +50,37 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"calendar +agenda": {"calendar", "+agenda", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"calendar +attendee-list": {"calendar", "+attendee-list", "--event", "event-1", "--calendar-id", "primary"},
|
||||
"calendar +book": {"calendar", "+book", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--with", "Fixture User", "--yes"},
|
||||
"calendar +book-search": {"calendar", "+book-search", "--query", "fixture"},
|
||||
"calendar +cancel-event": {"calendar", "+cancel-event", "--event", "event-1", "--yes"},
|
||||
"calendar +conflicts": {"calendar", "+conflicts", "--in-days", "1"},
|
||||
"calendar +create": {"calendar", "+create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--desc", "fixture description", "--attendees", "user-1,user-2", "--rooms", "room-1,room-2", "--calendar-id", "primary", "--yes"},
|
||||
"calendar +free": {"calendar", "+free", "--who", "Fixture User", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +free-slots": {"calendar", "+free-slots", "--from", "9", "--to", "18", "--in-days", "1"},
|
||||
"calendar +freebusy": {"calendar", "+freebusy", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +get": {"calendar", "+get", "--event", "event-1", "--calendar-id", "primary"},
|
||||
"calendar +invite": {"calendar", "+invite", "--event", "event-1", "--with", "Fixture User", "--yes"},
|
||||
"calendar +my-free": {"calendar", "+my-free", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +reschedule": {"calendar", "+reschedule", "--event", "event-1", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--yes"},
|
||||
"calendar +room-find": {"calendar", "+room-find", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--room-name", "Fixture Room", "--group-id", "group-1", "--page", "1", "--limit", "7"},
|
||||
"calendar +room-groups": {"calendar", "+room-groups", "--page", "1", "--limit", "7"},
|
||||
"calendar +room-search": {"calendar", "+room-search", "--room-name", "Fixture Room"},
|
||||
"calendar +rsvp": {"calendar", "+rsvp", "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"},
|
||||
"calendar +search-event": {"calendar", "+search-event", "--query", "fixture", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"calendar +suggest-time": {"calendar", "+suggest-time", "--with", "Fixture User", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
|
||||
"calendar +update": {"calendar", "+update", "--event", "event-1", "--title", "Fixture Updated Meeting", "--desc", "fixture updated description", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--add-attendees", "user-2", "--remove-attendees", "user-1", "--yes"},
|
||||
"calendar busy search": {"calendar", "busy", "search", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2"},
|
||||
"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"},
|
||||
"calendar event respond": {"calendar", "event", "respond", "--id", "event-1", "--status", "accepted"},
|
||||
"calendar event suggest": {"calendar", "event", "suggest", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
|
||||
"calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai"},
|
||||
"calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2"},
|
||||
"calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2"},
|
||||
"calendar room search": {"calendar", "room", "search", "--room-name", "Fixture Room", "--group-id", "group-1", "--start", "2027-03-10T09:00:00+08:00", "--end", "2027-03-10T10:00:00+08:00", "--page", "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"},
|
||||
@@ -192,12 +226,72 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
|
||||
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
|
||||
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
|
||||
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
|
||||
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
}
|
||||
|
||||
// paramAliasCandidateCompleteCommands contains complete invocations for the
|
||||
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
|
||||
// separate map lets this test file land before the draft replaces the formal
|
||||
// param_concepts.json: inactive candidate templates are ignored, while every
|
||||
// command becomes mandatory as soon as one of its reviewed aliases is active.
|
||||
var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
|
||||
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
|
||||
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
|
||||
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
|
||||
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
|
||||
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
|
||||
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
|
||||
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
|
||||
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
|
||||
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
|
||||
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
|
||||
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
|
||||
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
|
||||
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
|
||||
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
|
||||
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
|
||||
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
|
||||
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
|
||||
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
|
||||
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
|
||||
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
|
||||
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
|
||||
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
|
||||
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
|
||||
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
|
||||
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
|
||||
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
|
||||
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
|
||||
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
|
||||
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
|
||||
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
|
||||
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
|
||||
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
|
||||
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
|
||||
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
|
||||
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
|
||||
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
|
||||
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
|
||||
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
|
||||
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
|
||||
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
|
||||
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
|
||||
}
|
||||
|
||||
// A command can expose more than one mutually exclusive canonical route. In
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
@@ -497,6 +591,20 @@ var paramAliasNewConfirmationCases = []struct {
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
}
|
||||
|
||||
// Candidate confirmation cases become active with the joint draft. One write
|
||||
// workflow per product plus TODO's reminder workflow proves semantic aliasing
|
||||
// cannot move execution across the shared --yes barrier.
|
||||
var paramAliasCandidateConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
|
||||
{command: "todo +create", emitted: "deadline", canonical: "due"},
|
||||
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
|
||||
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
// old concept aliases, command overrides, native compatibility flags, read and
|
||||
// write commands, and different products. Every reviewed alias is still
|
||||
@@ -566,6 +674,132 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
// Candidate representatives exercise the final transport boundary for each
|
||||
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
|
||||
// exists in the loaded reviewed table, so the tests are mergeable before the
|
||||
// joint draft is promoted to internal/cli/param_concepts.json.
|
||||
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
|
||||
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
|
||||
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
|
||||
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
|
||||
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
|
||||
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
|
||||
paramAliasPayloadCaseKey("todo comment add", "text"): true,
|
||||
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
|
||||
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
|
||||
paramAliasPayloadCaseKey("todo task update", "status"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
|
||||
// separate from the long-lived app-c race process. Each case still executes
|
||||
// both canonical and alias argv through the real PreParse/Cobra path and
|
||||
// compares the final captured transport calls; the owning top-level test runs
|
||||
// these allocations in a short-lived race-instrumented subprocess so all Root
|
||||
// registrations are released together when that process exits.
|
||||
var paramAliasCalendarPayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "max-results"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "next-cursor"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +attendee-list", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +attendee-list", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book", "attendee-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "keyword"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "search"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "name"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free", "name"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "start-hour"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "end-hour"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "day-offset"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "room-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +my-free", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +my-free", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "participant-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-groups", "page-size"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-groups", "page-index"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-search", "query"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggest-time", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggest-time", "attendee-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +conflicts", "day-offset"): true,
|
||||
paramAliasPayloadCaseKey("calendar busy search", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "reminder-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar event respond", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar event suggest", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar event update", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar room add", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar room delete", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar room search", "room-group-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "description"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "room-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +get", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +get", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "page-size"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "page-index"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "room-group-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "query"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "keyword"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "next-cursor"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "description"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "add-user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarConfirmationCases selects one newly reviewed alias for
|
||||
// every Calendar Shortcut whose runtime contract requires user confirmation.
|
||||
// The complete Calendar matrix proves confirmed canonical/alias payload
|
||||
// equality; these representatives additionally prove semantic normalization
|
||||
// cannot cross the confirmation boundary before the first transport call.
|
||||
var paramAliasCalendarConfirmationCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
@@ -573,6 +807,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
|
||||
activeCommands := make(map[string]bool)
|
||||
activeFixtureCases := make(map[string]bool)
|
||||
activeCases := 0
|
||||
executedRepresentatives := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
@@ -581,6 +816,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
activeCommands[fixture.Command] = true
|
||||
activeCases++
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
activeFixtureCases[caseKey] = true
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
|
||||
@@ -593,60 +830,167 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
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")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(fixture.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)
|
||||
}
|
||||
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
|
||||
})
|
||||
}
|
||||
|
||||
if activeCases == 0 {
|
||||
t.Fatal("reviewed fixture contains no active alias cases")
|
||||
}
|
||||
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
|
||||
for command := range paramAliasCompleteCommands {
|
||||
if !activeCommands[command] {
|
||||
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
|
||||
}
|
||||
templateCommands[command] = true
|
||||
}
|
||||
for command := range paramAliasCandidateCompleteCommands {
|
||||
if activeCommands[command] {
|
||||
templateCommands[command] = true
|
||||
}
|
||||
}
|
||||
for command := range activeCommands {
|
||||
if _, ok := paramAliasCompleteCommands[command]; !ok {
|
||||
if !templateCommands[command] {
|
||||
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
|
||||
}
|
||||
}
|
||||
if len(activeCommands) != len(paramAliasCompleteCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
|
||||
if len(activeCommands) != len(templateCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
|
||||
}
|
||||
for caseKey := range paramAliasRepresentativePayloadCases {
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
|
||||
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
|
||||
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
|
||||
if !activeFixtureCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
activeRepresentatives++
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != activeRepresentatives {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
if os.Getenv(paramAliasCalendarPayloadChildEnv) != "1" {
|
||||
command := exec.Command(
|
||||
os.Args[0],
|
||||
"-test.run=^TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads$",
|
||||
"-test.count=1",
|
||||
"-test.timeout=5m",
|
||||
)
|
||||
command.Env = append(os.Environ(), paramAliasCalendarPayloadChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("Calendar param-alias payload subprocess failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
executed := make(map[string]bool)
|
||||
executedConfirmation := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasCalendarPayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executed[caseKey] = true
|
||||
fixture := fixture
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Calendar alias has no complete-command E2E template")
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete Calendar command must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Expect, replacements, canonicalArgs)
|
||||
}
|
||||
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
|
||||
if paramAliasCalendarConfirmationCases[caseKey] {
|
||||
executedConfirmation[caseKey] = true
|
||||
assertParamAliasCannotBypassConfirmation(t, aliasArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for caseKey := range paramAliasCalendarPayloadCases {
|
||||
if !executed[caseKey] {
|
||||
t.Errorf("Calendar final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executed) != len(paramAliasCalendarPayloadCases) {
|
||||
t.Fatalf("Calendar final-payload coverage = %d, want %d", len(executed), len(paramAliasCalendarPayloadCases))
|
||||
}
|
||||
for caseKey := range paramAliasCalendarConfirmationCases {
|
||||
if !executedConfirmation[caseKey] {
|
||||
t.Errorf("Calendar confirmation case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedConfirmation) != len(paramAliasCalendarConfirmationCases) {
|
||||
t.Fatalf("Calendar confirmation coverage = %d, want %d", len(executedConfirmation), len(paramAliasCalendarConfirmationCases))
|
||||
}
|
||||
}
|
||||
|
||||
func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string) {
|
||||
t.Helper()
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed Calendar alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed Calendar alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed Calendar alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
|
||||
t.Helper()
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
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")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -864,7 +1208,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewConfirmationCases {
|
||||
tests := append([]struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{}, paramAliasNewConfirmationCases...)
|
||||
for _, candidate := range paramAliasCandidateConfirmationCases {
|
||||
entry, exists := cli.LookupParamAlias(candidate.command)
|
||||
target, active := entry.ResolveAlias(candidate.emitted)
|
||||
if exists && active && target == candidate.canonical {
|
||||
tests = append(tests, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
@@ -946,6 +1303,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return complete, true
|
||||
}
|
||||
complete, ok = paramAliasCandidateCompleteCommands[command]
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -274,6 +274,7 @@ type agentExampleFiles struct {
|
||||
markdown string
|
||||
json string
|
||||
batch string
|
||||
job string
|
||||
binary string
|
||||
image string
|
||||
}
|
||||
@@ -283,12 +284,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
|
||||
markdown := filepath.Join(root, "content.md")
|
||||
jsonFile := filepath.Join(root, "report.json")
|
||||
batch := filepath.Join(root, "styles.json")
|
||||
job := filepath.Join(root, "job.json")
|
||||
binary := filepath.Join(root, "report.pdf")
|
||||
image := filepath.Join(root, "chart.png")
|
||||
for path, content := range map[string][]byte{
|
||||
markdown: []byte("# Agent dry-run fixture\n\nNo business call is allowed.\n"),
|
||||
jsonFile: []byte(`[{"content":"Agent dry-run fixture","sort":"0","key":"fixture","contentType":"markdown","type":"1"}]`),
|
||||
batch: []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`),
|
||||
job: []byte(`{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"minSalary":20000,"maxSalary":35000,"extData":{"headCount":1,"fullTimeExtData":{"salaryMonth":12}},"creatorUserId":"creator-user-id","ownerUserIds":["owner-user-id"]}`),
|
||||
binary: []byte("%PDF-1.4\n%%EOF\n"),
|
||||
image: {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'},
|
||||
} {
|
||||
@@ -301,6 +304,7 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles {
|
||||
markdown: "./" + filepath.Base(markdown),
|
||||
json: "./" + filepath.Base(jsonFile),
|
||||
batch: "./" + filepath.Base(batch),
|
||||
job: "./" + filepath.Base(job),
|
||||
binary: "./" + filepath.Base(binary),
|
||||
image: "./" + filepath.Base(image),
|
||||
}
|
||||
@@ -351,6 +355,10 @@ func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []strin
|
||||
replacement = files.markdown
|
||||
case "contents-file":
|
||||
replacement = files.json
|
||||
case "from":
|
||||
if strings.HasSuffix(strings.ToLower(value), "job.json") {
|
||||
replacement = files.job
|
||||
}
|
||||
case "batch":
|
||||
if strings.HasSuffix(strings.ToLower(value), "styles.json") {
|
||||
replacement = files.batch
|
||||
|
||||
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat emotion favorite",
|
||||
"chat emotion list",
|
||||
"chat emotion send",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
|
||||
@@ -136,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
|
||||
// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
|
||||
// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
tool := snapshot.Tools["oa.attachment_upload"]
|
||||
if tool == nil {
|
||||
t.Fatal("oa.attachment_upload is missing from final Schema")
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
|
||||
t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("interface_mode = %q, want composite", got)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Fatalf("availability = %q, want available", got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Fatal("composite upload command must document an interface reason")
|
||||
}
|
||||
if got := schemaContractString(tool["effect"]); got != "write" {
|
||||
t.Fatalf("effect = %q, want write", got)
|
||||
}
|
||||
if got := schemaContractString(tool["risk"]); got != "low" {
|
||||
t.Fatalf("risk = %q, want low", got)
|
||||
}
|
||||
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
|
||||
t.Fatalf("confirmation = %q, want not_required", got)
|
||||
}
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for _, flag := range []string{"file", "file-name", "md5"} {
|
||||
if parameters[flag] == nil {
|
||||
t.Fatalf("upload --%s is missing from final Schema", flag)
|
||||
}
|
||||
}
|
||||
if required, _ := parameters["file"]["required"].(bool); !required {
|
||||
t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
|
||||
}
|
||||
result := schemaContractMap(tool["result"])
|
||||
dataSchema := schemaContractMap(result["data_schema"])
|
||||
properties := schemaContractMap(dataSchema["properties"])
|
||||
if properties["fileId"] == nil {
|
||||
t.Fatal("upload Result data_schema is missing fileId")
|
||||
}
|
||||
}
|
||||
|
||||
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
|
||||
t.Helper()
|
||||
result, ok := tool["result"].(map[string]any)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func recruitSchemaLeaf(t *testing.T, canonical string, compact bool) map[string]any {
|
||||
t.Helper()
|
||||
root := NewRootCommand()
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
args := []string{"schema", canonical, "--format", "json"}
|
||||
if compact {
|
||||
args = append(args, "--compact")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute schema %s compact=%v: %v; stderr=%s", canonical, compact, err, stderr.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode schema %s compact=%v: %v", canonical, compact, err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestRecruitDeliveredSchemaPublishesResultAndPagination(t *testing.T) {
|
||||
for _, canonical := range []string{"recruit.list_jobs", "recruit.get_job_detail", "recruit.create_job"} {
|
||||
t.Run(canonical, func(t *testing.T) {
|
||||
full := recruitSchemaLeaf(t, canonical, false)
|
||||
compact := recruitSchemaLeaf(t, canonical, true)
|
||||
if full["result"] == nil || compact["result"] == nil {
|
||||
t.Fatalf("result missing: full=%#v compact=%#v", full["result"], compact["result"])
|
||||
}
|
||||
if !reflect.DeepEqual(full["result"], compact["result"]) {
|
||||
t.Fatalf("full/compact result mismatch\nfull=%#v\ncompact=%#v", full["result"], compact["result"])
|
||||
}
|
||||
if canonical == "recruit.list_jobs" {
|
||||
if full["pagination"] == nil || compact["pagination"] == nil {
|
||||
t.Fatalf("list pagination missing: full=%#v compact=%#v", full["pagination"], compact["pagination"])
|
||||
}
|
||||
if !reflect.DeepEqual(full["pagination"], compact["pagination"]) {
|
||||
t.Fatalf("full/compact pagination mismatch\nfull=%#v\ncompact=%#v", full["pagination"], compact["pagination"])
|
||||
}
|
||||
parameters, _ := full["parameters"].(map[string]any)
|
||||
cursor, _ := parameters["cursor"].(map[string]any)
|
||||
if cursor["type"] != "string" || cursor["interface_type"] != "number" {
|
||||
t.Fatalf("cursor contract = %#v, want CLI string converted to MCP number", cursor)
|
||||
}
|
||||
size, _ := parameters["size"].(map[string]any)
|
||||
if required, _ := size["required"].(bool); required {
|
||||
t.Fatalf("size required = true, want false: %#v", size)
|
||||
}
|
||||
if size["default"] != "20" {
|
||||
t.Fatalf("size default = %#v, want 20", size["default"])
|
||||
}
|
||||
compactParameters, _ := compact["parameters"].(map[string]any)
|
||||
compactSize, _ := compactParameters["size"].(map[string]any)
|
||||
if compactRequired, _ := compactSize["required"].(bool); compactRequired || compactSize["default"] != "20" {
|
||||
t.Fatalf("compact size contract = %#v, want required=false default=20", compactSize)
|
||||
}
|
||||
} else if full["pagination"] != nil || compact["pagination"] != nil {
|
||||
t.Fatalf("non-list pagination must be absent: full=%#v compact=%#v", full["pagination"], compact["pagination"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 435
|
||||
publicShortcutCount = 417
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 438
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 461
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 435
|
||||
publiclyDeliveredShortcutCount = 417
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 217; got != want {
|
||||
if got, want := int(product["count"].(float64)), 220; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
cliPath string
|
||||
params map[string]string
|
||||
}{
|
||||
{
|
||||
cliPath: "chat emotion list",
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion send",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"emotion-id": "emotionId",
|
||||
"group": "openConversationId",
|
||||
"open-dingtalk-id": "receiverOpenDingTalkId",
|
||||
"idempotency-key": "uuid",
|
||||
},
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion favorite",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"name": "name",
|
||||
"source-conversation-id": "sourceConversationId",
|
||||
"source-message-id": "sourceMessageId",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.cliPath, func(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
|
||||
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range tc.params {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
|
||||
result, _ := leaf["result"].(map[string]any)
|
||||
|
||||
+128
-26
@@ -117,38 +117,114 @@ type CliSkillDTO struct {
|
||||
// (skill_setup.go) MUST have a matching path value here — enforced by
|
||||
// TestAgentSkillPathsCoversSetupHomes.
|
||||
var agentSkillPaths = map[string]string{
|
||||
// `agents` is the generic-agent sentinel: install scripts and `setup`
|
||||
// special-case ~/.agents/skills as a no-checks-required fallback so a
|
||||
// fresh machine without any IDE/agent registry still gets skills.
|
||||
"agents": ".agents/skills",
|
||||
"qoder": ".qoder/skills",
|
||||
"qoderwork": ".qoderwork/skills",
|
||||
// Universal agents. Those without an independent global directory map
|
||||
// directly to the canonical ~/.agents/skills store.
|
||||
"agents": ".agents/skills",
|
||||
"amp": filepath.Join(".config", "agents", "skills"),
|
||||
"antigravity": ".gemini/antigravity/skills",
|
||||
"antigravity-cli": ".gemini/antigravity-cli/skills",
|
||||
"codex": ".codex/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"deepagents": ".deepagents/agent/skills",
|
||||
"firebender": ".firebender/skills",
|
||||
"gemini-cli": ".gemini/skills",
|
||||
"github-copilot": ".copilot/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
"replit": filepath.Join(".config", "agents", "skills"),
|
||||
"universal": filepath.Join(".config", "agents", "skills"),
|
||||
"cline": ".agents/skills",
|
||||
"dexto": ".agents/skills",
|
||||
"kimi-code-cli": ".agents/skills",
|
||||
"loaf": ".agents/skills",
|
||||
"warp": ".agents/skills",
|
||||
"zed": ".agents/skills",
|
||||
|
||||
// Non-universal agents with global Skill directories.
|
||||
"aider-desk": ".aider-desk/skills",
|
||||
"astrbot": ".astrbot/data/skills",
|
||||
"autohand-code": ".autohand/skills",
|
||||
"augment": ".augment/skills",
|
||||
"bob": ".bob/skills",
|
||||
"claude-code": ".claude/skills",
|
||||
"openclaw": ".openclaw/skills",
|
||||
"codearts-agent": ".codeartsdoer/skills",
|
||||
"codebuddy": ".codebuddy/skills",
|
||||
"codemaker": ".codemaker/skills",
|
||||
"codestudio": ".codestudio/skills",
|
||||
"command-code": ".commandcode/skills",
|
||||
"continue": ".continue/skills",
|
||||
"cortex": ".snowflake/cortex/skills",
|
||||
"crush": filepath.Join(".config", "crush", "skills"),
|
||||
"devin": filepath.Join(".config", "devin", "skills"),
|
||||
"droid": ".factory/skills",
|
||||
"forgecode": ".forge/skills",
|
||||
"goose": filepath.Join(".config", "goose", "skills"),
|
||||
"grok": ".grok/skills",
|
||||
"hermes-agent": ".hermes/skills",
|
||||
"inference-sh": ".inferencesh/skills",
|
||||
"jazz": ".jazz/skills",
|
||||
"junie": ".junie/skills",
|
||||
"iflow-cli": ".iflow/skills",
|
||||
"kilo": ".kilocode/skills",
|
||||
"kimchi": filepath.Join(".config", "kimchi", "harness", "skills"),
|
||||
"kiro-cli": ".kiro/skills",
|
||||
"kode": ".kode/skills",
|
||||
"lingma": ".lingma/skills",
|
||||
"mcpjam": ".mcpjam/skills",
|
||||
"minimax-code": ".minimax/skills",
|
||||
"mistral-vibe": ".vibe/skills",
|
||||
"moxby": ".moxby/skills",
|
||||
"mux": ".mux/skills",
|
||||
"openhands": ".openhands/skills",
|
||||
"ona": ".ona/skills",
|
||||
"pi": ".pi/agent/skills",
|
||||
"qoder": ".qoder/skills",
|
||||
"qoder-cn": ".qoder-cn/skills",
|
||||
"qwen-code": ".qwen/skills",
|
||||
"reasonix": ".reasonix/skills",
|
||||
"rovodev": ".rovodev/skills",
|
||||
"roo": ".roo/skills",
|
||||
"tabnine-cli": ".tabnine/agent/skills",
|
||||
"terramind": ".terramind/skills",
|
||||
"tinycloud": ".tinycloud/skills",
|
||||
"trae": ".trae/skills",
|
||||
"trae-cn": ".trae-cn/skills",
|
||||
"windsurf": ".codeium/windsurf/skills",
|
||||
"zcode": ".zcode/skills",
|
||||
"zencoder": ".zencoder/skills",
|
||||
"zenflow": ".zencoder/skills",
|
||||
"neovate": ".neovate/skills",
|
||||
"pochi": ".pochi/skills",
|
||||
"adal": ".adal/skills",
|
||||
|
||||
// DWS compatibility aliases and DWS-only integrations.
|
||||
"claude": ".claude/skills",
|
||||
"cursor": ".cursor/skills",
|
||||
"codex": ".codex/skills",
|
||||
"zcode": ".zcode/skills",
|
||||
"opencode": filepath.Join(".config", "opencode", "skills"),
|
||||
// IDE / agent registries also probed by `dws skill setup --target all`.
|
||||
"gemini": ".gemini/skills",
|
||||
"github": ".github/skills",
|
||||
"windsurf": ".windsurf/skills",
|
||||
"augment": ".augment/skills",
|
||||
"cline": ".cline/skills",
|
||||
"amp": ".amp/skills",
|
||||
"kiro": ".kiro/skills",
|
||||
"trae": ".trae/skills",
|
||||
"openclaw": ".openclaw/skills",
|
||||
"hermes": ".hermes/skills",
|
||||
"gemini": ".gemini/skills",
|
||||
"github": ".copilot/skills",
|
||||
"hermes": ".hermes/skills",
|
||||
"kiro": ".kiro/skills",
|
||||
"qoderwork": ".qoderwork/skills",
|
||||
}
|
||||
|
||||
// Eve has project-scoped Skill directories but no upstream globalSkillsDir.
|
||||
// Keep it in the advertised enumeration while failing explicitly instead of
|
||||
// pretending that a global install configured Eve.
|
||||
var unsupportedGlobalAgentTargets = map[string]string{
|
||||
"eve": "Eve 不支持全局 Skill 安装,请在 Eve 项目内配置 agent/skills",
|
||||
"promptscript": "PromptScript 不支持全局 Skill 安装,请在项目内使用 .agents/skills",
|
||||
}
|
||||
|
||||
// supportedTargets returns a sorted, comma-separated list of supported
|
||||
// targets. Sorted so help text and error messages stay stable across runs
|
||||
// (Go map iteration order is intentionally randomized).
|
||||
func supportedTargets() string {
|
||||
targets := make([]string, 0, len(agentSkillPaths)+1)
|
||||
targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+1)
|
||||
for target := range agentSkillPaths {
|
||||
targets = append(targets, target)
|
||||
}
|
||||
for target := range unsupportedGlobalAgentTargets {
|
||||
targets = append(targets, target)
|
||||
}
|
||||
sort.Strings(targets)
|
||||
targets = append(targets, ".")
|
||||
return strings.Join(targets, ", ")
|
||||
@@ -182,11 +258,28 @@ func formatAgentSkillPathsForHelp() string {
|
||||
sort.Strings(names)
|
||||
var b strings.Builder
|
||||
for _, n := range names {
|
||||
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n])
|
||||
installPath := agentSkillPaths[n]
|
||||
if isUniversalSkillInstallTarget(n) {
|
||||
installPath = ".agents/skills"
|
||||
}
|
||||
fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Universal Agents discover the shared ~/.agents/skills store directly. A
|
||||
// marketplace install addressed to one of those Agent IDs must therefore
|
||||
// publish to canonical instead of recreating an Agent-private duplicate.
|
||||
func isUniversalSkillInstallTarget(target string) bool {
|
||||
rel, ok := agentSkillPaths[target]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
base := filepath.Join("__home__", rel)
|
||||
canonical := filepath.Join("__home__", ".agents", "skills")
|
||||
return sameSkillSetupPath(base, canonical) || isUniversalSkillSetupBase(base)
|
||||
}
|
||||
|
||||
func buildSkillCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "skill",
|
||||
@@ -485,8 +578,13 @@ func resolveSkillTargetPath(target string) (string, error) {
|
||||
return os.Getwd()
|
||||
}
|
||||
|
||||
// Look up predefined agent paths
|
||||
relPath, ok := agentSkillPaths[strings.ToLower(target)]
|
||||
target = strings.ToLower(target)
|
||||
if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
|
||||
return "", errors.New(reason)
|
||||
}
|
||||
|
||||
// Look up predefined agent paths.
|
||||
_, ok := agentSkillPaths[target]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("unsupported target")
|
||||
}
|
||||
@@ -496,7 +594,11 @@ func resolveSkillTargetPath(target string) (string, error) {
|
||||
return "", fmt.Errorf("failed to get home directory: %w", err)
|
||||
}
|
||||
|
||||
return filepath.Join(homeDir, relPath), nil
|
||||
destination := resolveSkillSetupBase(homeDir, target)
|
||||
if isUniversalSkillInstallTarget(target) {
|
||||
destination = filepath.Join(homeDir, ".agents", "skills")
|
||||
}
|
||||
return destination, nil
|
||||
}
|
||||
|
||||
// fetchSkillDownloadInfo calls the download API to get the skill download URL.
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestResolveSkillTargetPath(t *testing.T) {
|
||||
@@ -57,19 +58,19 @@ func TestResolveSkillTargetPath(t *testing.T) {
|
||||
{
|
||||
name: "cursor target",
|
||||
target: "cursor",
|
||||
wantSuffix: filepath.Join(".cursor", "skills"),
|
||||
wantSuffix: filepath.Join(".agents", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "codex target",
|
||||
target: "codex",
|
||||
wantSuffix: filepath.Join(".codex", "skills"),
|
||||
wantSuffix: filepath.Join(".agents", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "opencode target",
|
||||
target: "opencode",
|
||||
wantSuffix: filepath.Join(".config", "opencode", "skills"),
|
||||
wantSuffix: filepath.Join(".agents", "skills"),
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
@@ -118,6 +119,37 @@ func TestResolveSkillTargetPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil })
|
||||
if isUniversalSkillInstallTarget("missing-agent") {
|
||||
t.Fatal("unknown Agent target classified as universal")
|
||||
}
|
||||
for _, target := range []string{
|
||||
"amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor",
|
||||
"deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github",
|
||||
"github-copilot", "kimi-code-cli", "loaf", "opencode", "replit",
|
||||
"universal", "warp", "zed",
|
||||
} {
|
||||
got, err := resolveSkillTargetPath(target)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve %s: %v", target, err)
|
||||
}
|
||||
if want := filepath.Join(home, ".agents", "skills"); got != want {
|
||||
t.Errorf("resolve %s = %s, want canonical %s", target, got, want)
|
||||
}
|
||||
}
|
||||
for target, want := range map[string]string{
|
||||
"claude": filepath.Join(home, ".claude", "skills"),
|
||||
"qoder": filepath.Join(home, ".qoder", "skills"),
|
||||
"zcode": filepath.Join(home, ".zcode", "skills"),
|
||||
} {
|
||||
if got, err := resolveSkillTargetPath(target); err != nil || got != want {
|
||||
t.Errorf("resolve non-universal %s = %s, %v; want %s", target, got, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSkillTargetPathCurrentDir(t *testing.T) {
|
||||
// Test "." target returns current working directory
|
||||
cwd, err := os.Getwd()
|
||||
@@ -477,8 +509,12 @@ func TestAgentSkillPathsCoversSetupHomes(t *testing.T) {
|
||||
for _, p := range agentSkillPaths {
|
||||
paths[p] = true
|
||||
}
|
||||
legacyCleanupOnly := map[string]bool{
|
||||
".github/skills": true, ".windsurf/skills": true,
|
||||
".cline/skills": true, ".amp/skills": true,
|
||||
}
|
||||
for _, home := range skillSetupAgentHomes {
|
||||
if !paths[home] {
|
||||
if !paths[home] && !legacyCleanupOnly[home] {
|
||||
t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+
|
||||
"add it to agentSkillPaths so users can address it via --target <name>", home)
|
||||
}
|
||||
|
||||
+459
-125
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,22 +24,77 @@ import (
|
||||
// agree on the install footprint.
|
||||
var skillSetupAgentHomes = []string{
|
||||
".agents/skills",
|
||||
".config/agents/skills",
|
||||
".gemini/antigravity/skills",
|
||||
".gemini/antigravity-cli/skills",
|
||||
".deepagents/agent/skills",
|
||||
".firebender/skills",
|
||||
".copilot/skills",
|
||||
".config/opencode/skills",
|
||||
".aider-desk/skills",
|
||||
".astrbot/data/skills",
|
||||
".autohand/skills",
|
||||
".augment/skills",
|
||||
".bob/skills",
|
||||
".claude/skills",
|
||||
".cursor/skills",
|
||||
".openclaw/skills",
|
||||
".codeartsdoer/skills",
|
||||
".codebuddy/skills",
|
||||
".codemaker/skills",
|
||||
".codestudio/skills",
|
||||
".commandcode/skills",
|
||||
".continue/skills",
|
||||
".snowflake/cortex/skills",
|
||||
".config/crush/skills",
|
||||
".config/devin/skills",
|
||||
".factory/skills",
|
||||
".forge/skills",
|
||||
".config/goose/skills",
|
||||
".grok/skills",
|
||||
".hermes/skills",
|
||||
".inferencesh/skills",
|
||||
".jazz/skills",
|
||||
".junie/skills",
|
||||
".iflow/skills",
|
||||
".kilocode/skills",
|
||||
".config/kimchi/harness/skills",
|
||||
".kiro/skills",
|
||||
".kode/skills",
|
||||
".lingma/skills",
|
||||
".mcpjam/skills",
|
||||
".minimax/skills",
|
||||
".vibe/skills",
|
||||
".moxby/skills",
|
||||
".mux/skills",
|
||||
".openhands/skills",
|
||||
".ona/skills",
|
||||
".pi/agent/skills",
|
||||
".qoder/skills",
|
||||
".qoder-cn/skills",
|
||||
".qwen/skills",
|
||||
".reasonix/skills",
|
||||
".rovodev/skills",
|
||||
".roo/skills",
|
||||
".tabnine/agent/skills",
|
||||
".terramind/skills",
|
||||
".tinycloud/skills",
|
||||
".trae/skills",
|
||||
".trae-cn/skills",
|
||||
".codeium/windsurf/skills",
|
||||
".zcode/skills",
|
||||
".zencoder/skills",
|
||||
".neovate/skills",
|
||||
".pochi/skills",
|
||||
".adal/skills",
|
||||
".qoderwork/skills",
|
||||
// beta.6 compatibility roots: cleanup only.
|
||||
".cursor/skills",
|
||||
".gemini/skills",
|
||||
".codex/skills",
|
||||
".zcode/skills",
|
||||
".github/skills",
|
||||
".windsurf/skills",
|
||||
".augment/skills",
|
||||
".cline/skills",
|
||||
".amp/skills",
|
||||
".kiro/skills",
|
||||
".trae/skills",
|
||||
".openclaw/skills",
|
||||
".hermes/skills",
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -65,15 +121,20 @@ var (
|
||||
skillSetupInteractive = isInteractiveTerminal
|
||||
skillSetupReadDir = os.ReadDir
|
||||
skillSetupStat = os.Stat
|
||||
skillSetupLstat = os.Lstat
|
||||
skillSetupGetenv = os.Getenv
|
||||
skillSetupSymlink = os.Symlink
|
||||
skillSetupExecutable = os.Executable
|
||||
skillSetupGetwd = os.Getwd
|
||||
skillSetupUserHomeDir = os.UserHomeDir
|
||||
skillSetupRemoveAll = os.RemoveAll
|
||||
skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir
|
||||
skillSetupRestoreBackup = upgrade.RestoreSkillPath
|
||||
skillSetupMkdirAll = os.MkdirAll
|
||||
skillSetupWalk = filepath.Walk
|
||||
skillSetupRel = filepath.Rel
|
||||
skillSetupReadlink = os.Readlink
|
||||
skillSetupEvalSymlinks = filepath.EvalSymlinks
|
||||
skillSetupOpen = os.Open
|
||||
skillSetupOpenFile = os.OpenFile
|
||||
skillSetupWriteFile = os.WriteFile
|
||||
@@ -82,7 +143,10 @@ var (
|
||||
skillSetupReadState = skillstate.Read
|
||||
skillSetupWriteState = skillstate.Write
|
||||
skillSetupRemoveState = skillstate.Remove
|
||||
skillSetupPublishPath = upgrade.PublishSkillPathNoReplace
|
||||
skillSetupRollbackPaths = upgrade.RollbackSkillPathPublications
|
||||
skillSetupNow = time.Now
|
||||
skillSetupFoldPathCase = runtime.GOOS == "windows"
|
||||
)
|
||||
|
||||
type skillSetupBackup struct {
|
||||
@@ -91,9 +155,11 @@ type skillSetupBackup struct {
|
||||
}
|
||||
|
||||
type skillSetupTargetPlan struct {
|
||||
Destination string
|
||||
Backups []skillSetupBackup
|
||||
CleanupOnly bool
|
||||
Destination string
|
||||
CanonicalBase string
|
||||
Backups []skillSetupBackup
|
||||
CleanupOnly bool
|
||||
LinkCanonical bool
|
||||
}
|
||||
|
||||
type skillSetupPlan struct {
|
||||
@@ -149,8 +215,9 @@ multi 模式支持按产品挑选:
|
||||
备份失败时保留原目录并跳过该目标,绝不静默删除。
|
||||
· 所有将被移除的目录都会在确认前逐条列出。
|
||||
|
||||
不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录;
|
||||
未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。
|
||||
不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。
|
||||
DWS 会自动适配本机上检测到的 Agent;共享安装方式不可用时会自动改用兼容安装,
|
||||
无需用户手动处理,也不会让同一个 Skill 重复出现。
|
||||
skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`,
|
||||
Example: ` dws skill setup --mode multi --target claude --dry-run
|
||||
dws skill setup --mode multi --target claude`,
|
||||
@@ -243,7 +310,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude
|
||||
// 时保持 additive(不动未列出的 sibling);全量安装与 install.sh /
|
||||
// install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。
|
||||
@@ -316,6 +382,17 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 {
|
||||
retiredNames := append([]string(nil), multiSkillNames...)
|
||||
if installsEventMiscCompanion && !containsSkillName(retiredNames, multiMiscSkill) {
|
||||
retiredNames = append(retiredNames, multiMiscSkill)
|
||||
}
|
||||
if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil {
|
||||
// Retiring an obsolete universal copy installs nothing; report it and
|
||||
// keep the successful installation rather than failing the run.
|
||||
fmt.Fprintf(errOut, " ⚠️ %v\n", retireErr)
|
||||
}
|
||||
}
|
||||
if skipped > 0 {
|
||||
return fmt.Errorf(
|
||||
"Skill 安装不完整(mode=%s, installed=%d, skipped=%d);修复失败原因后请重试 setup,或运行普通 upgrade 全量刷新预制 Skill",
|
||||
@@ -350,7 +427,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
|
||||
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
|
||||
fmt.Fprintln(out, " 统一安装位置:~/.agents/skills")
|
||||
fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent")
|
||||
fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -910,8 +989,9 @@ func isSkillSourceRoot(path, mode string) bool {
|
||||
}
|
||||
|
||||
// resolveSkillSetupTargets returns the list of absolute Agent home destinations.
|
||||
// If target == "all", returns every agent home whose parent directory exists.
|
||||
// Otherwise returns the single matching home (whether or not it currently exists).
|
||||
// The canonical ~/.agents/skills destination is always first. If target ==
|
||||
// "all", detected concrete Agent roots follow it. A specific target follows
|
||||
// canonical as well so unknown/future Agents retain the universal copy.
|
||||
//
|
||||
// 末段约定:
|
||||
// - mono → <agent-home>/dws (单 skill,整个 src 拷成一个 dws 目录)
|
||||
@@ -923,15 +1003,88 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) {
|
||||
}
|
||||
|
||||
target = strings.ToLower(strings.TrimSpace(target))
|
||||
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
|
||||
if target == "" || target == "all" {
|
||||
return detectExistingAgentHomes(home, mode), nil
|
||||
}
|
||||
|
||||
rel, ok := agentSkillPaths[target]
|
||||
if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported {
|
||||
return nil, errors.New(reason)
|
||||
}
|
||||
_, ok := agentSkillPaths[target]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets())
|
||||
}
|
||||
return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil
|
||||
dest := agentHomeForMode(resolveSkillSetupBase(home, target), mode)
|
||||
if sameSkillSetupPath(dest, canonical) {
|
||||
return []string{canonical}, nil
|
||||
}
|
||||
return []string{canonical, dest}, nil
|
||||
}
|
||||
|
||||
func resolveOpenClawSetupBase(home string) string {
|
||||
for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} {
|
||||
base := filepath.Join(home, name)
|
||||
if info, err := skillSetupStat(base); err == nil && info.IsDir() {
|
||||
return filepath.Join(base, "skills")
|
||||
}
|
||||
}
|
||||
return filepath.Join(home, ".openclaw", "skills")
|
||||
}
|
||||
|
||||
func resolveSkillSetupBase(home, target string) string {
|
||||
switch target {
|
||||
case "claude", "claude-code":
|
||||
if custom := strings.TrimSpace(skillSetupGetenv("CLAUDE_CONFIG_DIR")); custom != "" {
|
||||
return filepath.Join(custom, "skills")
|
||||
}
|
||||
case "codex":
|
||||
if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" {
|
||||
return filepath.Join(custom, "skills")
|
||||
}
|
||||
case "hermes", "hermes-agent":
|
||||
if custom := strings.TrimSpace(skillSetupGetenv("HERMES_HOME")); custom != "" {
|
||||
return filepath.Join(custom, "skills")
|
||||
}
|
||||
case "autohand-code":
|
||||
if custom := strings.TrimSpace(skillSetupGetenv("AUTOHAND_HOME")); custom != "" {
|
||||
return filepath.Join(custom, "skills")
|
||||
}
|
||||
case "grok":
|
||||
if custom := strings.TrimSpace(skillSetupGetenv("GROK_HOME")); custom != "" {
|
||||
return filepath.Join(custom, "skills")
|
||||
}
|
||||
case "mistral-vibe":
|
||||
if custom := strings.TrimSpace(skillSetupGetenv("VIBE_HOME")); custom != "" {
|
||||
return filepath.Join(custom, "skills")
|
||||
}
|
||||
case "openclaw":
|
||||
return resolveOpenClawSetupBase(home)
|
||||
case "opencode", "amp", "replit", "universal", "crush", "devin", "goose", "kimchi":
|
||||
configHome := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME"))
|
||||
if configHome == "" {
|
||||
configHome = filepath.Join(home, ".config")
|
||||
}
|
||||
switch target {
|
||||
case "opencode":
|
||||
return filepath.Join(configHome, "opencode", "skills")
|
||||
case "amp", "replit", "universal":
|
||||
return filepath.Join(configHome, "agents", "skills")
|
||||
case "crush":
|
||||
return filepath.Join(configHome, "crush", "skills")
|
||||
case "devin":
|
||||
return filepath.Join(configHome, "devin", "skills")
|
||||
case "goose":
|
||||
return filepath.Join(configHome, "goose", "skills")
|
||||
case "kimchi":
|
||||
return filepath.Join(configHome, "kimchi", "harness", "skills")
|
||||
}
|
||||
case "github", "github-copilot":
|
||||
return filepath.Join(home, ".copilot", "skills")
|
||||
case "windsurf":
|
||||
return filepath.Join(home, ".codeium", "windsurf", "skills")
|
||||
}
|
||||
return filepath.Join(home, agentSkillPaths[target])
|
||||
}
|
||||
|
||||
// agentHomeForMode appends the mode-specific tail segment to an agent home base.
|
||||
@@ -943,79 +1096,141 @@ func agentHomeForMode(base, mode string) string {
|
||||
}
|
||||
|
||||
func detectExistingAgentHomes(home, mode string) []string {
|
||||
var specific []string
|
||||
canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)
|
||||
dests := []string{canonical}
|
||||
canonicalKey := skillSetupPathKey(canonical)
|
||||
seen := map[string]bool{canonicalKey: true}
|
||||
addDetected := func(rel, base string) {
|
||||
detectedDir := filepath.Dir(base)
|
||||
switch filepath.ToSlash(filepath.Clean(rel)) {
|
||||
case ".config/kimchi/harness/skills", ".tabnine/agent/skills":
|
||||
detectedDir = filepath.Dir(filepath.Dir(base))
|
||||
case ".zcode/skills":
|
||||
// Application bundles are machine-scoped detection signals. Keep this
|
||||
// independent of HOME so setup matches npm, Shell, and PowerShell.
|
||||
if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() {
|
||||
detectedDir = ""
|
||||
}
|
||||
case ".minimax/skills":
|
||||
if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() {
|
||||
detectedDir = ""
|
||||
}
|
||||
}
|
||||
if detectedDir != "" {
|
||||
if info, err := skillSetupStat(detectedDir); err != nil || !info.IsDir() {
|
||||
return
|
||||
}
|
||||
}
|
||||
dest := agentHomeForMode(base, mode)
|
||||
key := skillSetupPathKey(dest)
|
||||
if !seen[key] {
|
||||
seen[key] = true
|
||||
dests = append(dests, dest)
|
||||
}
|
||||
}
|
||||
for i, rel := range skillSetupAgentHomes {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
base := filepath.Join(home, rel)
|
||||
parent := filepath.Dir(base)
|
||||
if info, err := skillSetupStat(parent); err != nil || !info.IsDir() {
|
||||
continue
|
||||
switch filepath.ToSlash(filepath.Clean(rel)) {
|
||||
case ".claude/skills":
|
||||
base = resolveSkillSetupBase(home, "claude-code")
|
||||
case ".codex/skills":
|
||||
base = resolveSkillSetupBase(home, "codex")
|
||||
case ".hermes/skills":
|
||||
base = resolveSkillSetupBase(home, "hermes-agent")
|
||||
case ".autohand/skills":
|
||||
base = resolveSkillSetupBase(home, "autohand-code")
|
||||
case ".grok/skills":
|
||||
base = resolveSkillSetupBase(home, "grok")
|
||||
case ".vibe/skills":
|
||||
base = resolveSkillSetupBase(home, "mistral-vibe")
|
||||
case ".openclaw/skills":
|
||||
base = resolveSkillSetupBase(home, "openclaw")
|
||||
case ".config/opencode/skills":
|
||||
base = resolveSkillSetupBase(home, "opencode")
|
||||
case ".config/agents/skills":
|
||||
base = resolveSkillSetupBase(home, "amp")
|
||||
case ".config/crush/skills":
|
||||
base = resolveSkillSetupBase(home, "crush")
|
||||
case ".config/devin/skills":
|
||||
base = resolveSkillSetupBase(home, "devin")
|
||||
case ".config/goose/skills":
|
||||
base = resolveSkillSetupBase(home, "goose")
|
||||
case ".config/kimchi/harness/skills":
|
||||
base = resolveSkillSetupBase(home, "kimchi")
|
||||
}
|
||||
specific = append(specific, agentHomeForMode(base, mode))
|
||||
addDetected(rel, base)
|
||||
}
|
||||
if len(specific) > 0 {
|
||||
return specific
|
||||
for _, target := range []string{"github-copilot", "windsurf"} {
|
||||
addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target))
|
||||
}
|
||||
return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)}
|
||||
return dests
|
||||
}
|
||||
|
||||
func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) {
|
||||
// Derive HOME from a concrete Agent destination instead of resolving it a
|
||||
// second time. The destinations were already resolved from HOME by the
|
||||
// caller, and a later/transient UserHomeDir failure must not turn an
|
||||
// otherwise valid setup plan into an error. Direct/custom destinations that
|
||||
// do not match a known concrete Agent root have no generic-root migration.
|
||||
home := ""
|
||||
for _, dest := range dests {
|
||||
base := dest
|
||||
if filepath.Base(dest) == "dws" {
|
||||
base = filepath.Dir(dest)
|
||||
}
|
||||
base = filepath.Clean(base)
|
||||
for i, rel := range skillSetupAgentHomes {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
suffix := filepath.Clean(filepath.FromSlash(rel))
|
||||
needle := string(filepath.Separator) + suffix
|
||||
if strings.HasSuffix(base, needle) {
|
||||
home = strings.TrimSuffix(base, needle)
|
||||
break
|
||||
}
|
||||
}
|
||||
if home != "" {
|
||||
break
|
||||
}
|
||||
func skillSetupBaseForMode(dest, mode string) string {
|
||||
if mode == skillSetupModeMono {
|
||||
return filepath.Dir(dest)
|
||||
}
|
||||
if home == "" {
|
||||
return nil, nil
|
||||
}
|
||||
genericBase := filepath.Join(home, ".agents", "skills")
|
||||
return dest
|
||||
}
|
||||
|
||||
target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true}
|
||||
add := func(path, reason string) {
|
||||
if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() {
|
||||
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason})
|
||||
func isUniversalSkillSetupBase(base string) bool {
|
||||
cleanBase := filepath.Clean(base)
|
||||
if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" && sameSkillSetupPath(cleanBase, filepath.Join(custom, "skills")) {
|
||||
return true
|
||||
}
|
||||
if custom := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")); custom != "" {
|
||||
if sameSkillSetupPath(cleanBase, filepath.Join(custom, "agents", "skills")) ||
|
||||
sameSkillSetupPath(cleanBase, filepath.Join(custom, "opencode", "skills")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual)
|
||||
entries, readErr := skillSetupReadDir(genericBase)
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
path := filepath.Join(genericBase, entry.Name())
|
||||
if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) {
|
||||
target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale})
|
||||
base = filepath.ToSlash(cleanBase)
|
||||
for rel := range map[string]bool{
|
||||
".config/agents/skills": true, ".gemini/antigravity/skills": true,
|
||||
".gemini/antigravity-cli/skills": true, ".codex/skills": true,
|
||||
".cursor/skills": true, ".deepagents/agent/skills": true,
|
||||
".firebender/skills": true, ".gemini/skills": true,
|
||||
".copilot/skills": true, ".config/opencode/skills": true,
|
||||
// beta.6 compatibility roots are cleanup-only.
|
||||
".github/skills": true, ".windsurf/skills": true,
|
||||
".cline/skills": true, ".amp/skills": true,
|
||||
} {
|
||||
if strings.HasSuffix(base, "/"+rel) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(target.Backups) == 0 {
|
||||
return nil, nil
|
||||
return false
|
||||
}
|
||||
|
||||
func sameSkillSetupPath(left, right string) bool {
|
||||
return skillSetupPathKey(left) == skillSetupPathKey(right)
|
||||
}
|
||||
|
||||
func skillSetupPathKey(path string) string {
|
||||
clean := filepath.Clean(path)
|
||||
if skillSetupFoldPathCase {
|
||||
clean = strings.ToLower(clean)
|
||||
}
|
||||
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
|
||||
return target, nil
|
||||
return clean
|
||||
}
|
||||
|
||||
func canonicalSkillSetupBase(dests []string, mode string) string {
|
||||
for _, dest := range dests {
|
||||
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode)))
|
||||
if strings.HasSuffix(base, "/.agents/skills") {
|
||||
return skillSetupBaseForMode(dest, mode)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func samePhysicalSkillSetupPath(left, right string) bool {
|
||||
leftReal, leftErr := skillSetupEvalSymlinks(left)
|
||||
rightReal, rightErr := skillSetupEvalSymlinks(right)
|
||||
return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal)
|
||||
}
|
||||
|
||||
func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) {
|
||||
@@ -1030,10 +1245,26 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
}
|
||||
sort.Strings(plan.MultiSkillNames)
|
||||
sortedDests := append([]string(nil), dests...)
|
||||
sort.Strings(sortedDests)
|
||||
sort.Slice(sortedDests, func(i, j int) bool {
|
||||
leftCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[i], mode))), "/.agents/skills")
|
||||
rightCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[j], mode))), "/.agents/skills")
|
||||
if leftCanonical != rightCanonical {
|
||||
return leftCanonical
|
||||
}
|
||||
return sortedDests[i] < sortedDests[j]
|
||||
})
|
||||
managedNames := currentManagedSkillNames()
|
||||
canonicalBase := canonicalSkillSetupBase(sortedDests, mode)
|
||||
for _, dest := range sortedDests {
|
||||
target := skillSetupTargetPlan{Destination: dest}
|
||||
base := skillSetupBaseForMode(dest, mode)
|
||||
target := skillSetupTargetPlan{Destination: dest, CanonicalBase: canonicalBase}
|
||||
if canonicalBase != "" && filepath.Clean(base) != filepath.Clean(canonicalBase) {
|
||||
if isUniversalSkillSetupBase(base) {
|
||||
target.CleanupOnly = true
|
||||
} else {
|
||||
target.LinkCanonical = true
|
||||
}
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
add := func(path, reason string) {
|
||||
if seen[path] {
|
||||
@@ -1077,26 +1308,22 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt
|
||||
}
|
||||
}
|
||||
for _, path := range replacements {
|
||||
info, statErr := skillSetupStat(path)
|
||||
if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) {
|
||||
continue
|
||||
}
|
||||
_, statErr := skillSetupLstat(path)
|
||||
if statErr != nil {
|
||||
if errors.Is(statErr, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr)
|
||||
}
|
||||
if info.IsDir() {
|
||||
add(path, skillSetupBackupReplace)
|
||||
}
|
||||
add(path, skillSetupBackupReplace)
|
||||
}
|
||||
sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path })
|
||||
plan.Targets = append(plan.Targets, target)
|
||||
}
|
||||
cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames)
|
||||
if cleanupErr != nil {
|
||||
return nil, cleanupErr
|
||||
}
|
||||
if cleanupTarget != nil {
|
||||
plan.Targets = append(plan.Targets, *cleanupTarget)
|
||||
if !target.CleanupOnly || len(target.Backups) > 0 {
|
||||
plan.Targets = append(plan.Targets, target)
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
@@ -1140,6 +1367,33 @@ func configureEventMiscMigrationPlan(plan *skillSetupPlan, targets []string, ins
|
||||
}
|
||||
}
|
||||
|
||||
func retireMigratedUniversalSkills(targets, names []string, out io.Writer) error {
|
||||
home, err := skillSetupUserHomeDir()
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析 HOME 以退役 universal Agent 旧副本: %w", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var victims []skillSetupBackup
|
||||
for _, target := range targets {
|
||||
if !isUniversalSkillSetupBase(target) {
|
||||
continue
|
||||
}
|
||||
for _, name := range names {
|
||||
path := filepath.Join(target, name)
|
||||
if seen[path] {
|
||||
continue
|
||||
}
|
||||
seen[path] = true
|
||||
victims = append(victims, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace})
|
||||
}
|
||||
}
|
||||
sort.Slice(victims, func(i, j int) bool { return victims[i].Path < victims[j].Path })
|
||||
if _, err := backupSkillSetupTarget(home, victims, out); err != nil {
|
||||
return fmt.Errorf("退役 universal Agent Event/misc 旧副本失败,已回滚: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
|
||||
fmt.Fprintf(out, "📦 将安装 skill:\n mode: %s\n source: %s\n", plan.Mode, plan.Source)
|
||||
if plan.Mode == skillSetupModeMulti {
|
||||
@@ -1148,12 +1402,14 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) {
|
||||
fmt.Fprintf(out, " · %s\n", name)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(out, " destinations:")
|
||||
fmt.Fprintln(out, " 安装与适配位置:")
|
||||
for _, target := range plan.Targets {
|
||||
if target.CleanupOnly {
|
||||
fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination)
|
||||
fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination)
|
||||
} else if target.LinkCanonical {
|
||||
fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination)
|
||||
} else {
|
||||
fmt.Fprintf(out, " - %s\n", target.Destination)
|
||||
fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination)
|
||||
}
|
||||
}
|
||||
fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):")
|
||||
@@ -1324,8 +1580,12 @@ func installMultiSkillsWithEventMigration(
|
||||
}
|
||||
|
||||
migrationSet := make(map[string]struct{}, len(migrationTargets))
|
||||
physicalMigrationTargets := make([]string, 0, len(migrationTargets))
|
||||
for _, dest := range migrationTargets {
|
||||
migrationSet[dest] = struct{}{}
|
||||
if !isUniversalSkillSetupBase(dest) {
|
||||
physicalMigrationTargets = append(physicalMigrationTargets, dest)
|
||||
}
|
||||
}
|
||||
var ordinaryTargets []string
|
||||
for _, dest := range dests {
|
||||
@@ -1334,23 +1594,47 @@ func installMultiSkillsWithEventMigration(
|
||||
}
|
||||
}
|
||||
|
||||
if len(ordinaryTargets) > 0 {
|
||||
var canonicalTargets, otherOrdinaryTargets []string
|
||||
for _, dest := range ordinaryTargets {
|
||||
base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, skillSetupModeMulti)))
|
||||
if strings.HasSuffix(base, "/.agents/skills") {
|
||||
canonicalTargets = append(canonicalTargets, dest)
|
||||
} else {
|
||||
otherOrdinaryTargets = append(otherOrdinaryTargets, dest)
|
||||
}
|
||||
}
|
||||
installOrdinary := func(names, targets []string) error {
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
var n, nSkipped int
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut, filtered)
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered)
|
||||
installed += n
|
||||
skipped += nSkipped
|
||||
if err != nil {
|
||||
return installed, skipped, err
|
||||
return err
|
||||
}
|
||||
if nSkipped > 0 {
|
||||
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
|
||||
return fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
canonicalNames := append([]string(nil), skillNames...)
|
||||
if !containsSkillName(canonicalNames, multiMiscSkill) {
|
||||
canonicalNames = append(canonicalNames, multiMiscSkill)
|
||||
sort.Strings(canonicalNames)
|
||||
}
|
||||
if err := installOrdinary(canonicalNames, canonicalTargets); err != nil {
|
||||
return installed, skipped, err
|
||||
}
|
||||
if err := installOrdinary(skillNames, otherOrdinaryTargets); err != nil {
|
||||
return installed, skipped, err
|
||||
}
|
||||
|
||||
// The folded pair is excluded from the ordinary best-effort installer. All
|
||||
// other selected skills (especially dingtalk-shared) must succeed before the
|
||||
// old Event route is touched.
|
||||
for _, dest := range migrationTargets {
|
||||
for _, dest := range physicalMigrationTargets {
|
||||
if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil {
|
||||
return installed, skipped + len(skillNames), cleanupErr
|
||||
}
|
||||
@@ -1361,9 +1645,9 @@ func installMultiSkillsWithEventMigration(
|
||||
prerequisiteNames = append(prerequisiteNames, name)
|
||||
}
|
||||
}
|
||||
if len(prerequisiteNames) > 0 {
|
||||
if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 {
|
||||
var n, nSkipped int
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut, true)
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true)
|
||||
installed += n
|
||||
skipped += nSkipped
|
||||
if err != nil {
|
||||
@@ -1374,7 +1658,7 @@ func installMultiSkillsWithEventMigration(
|
||||
}
|
||||
}
|
||||
|
||||
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
|
||||
migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut)
|
||||
installed += migrated
|
||||
if migrationErr != nil {
|
||||
return installed, skipped, migrationErr
|
||||
@@ -1646,9 +1930,32 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
|
||||
err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
}()
|
||||
realStageParent, realParentErr := skillSetupEvalSymlinks(stageParent)
|
||||
if realParentErr != nil {
|
||||
return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", stageParent, realParentErr)
|
||||
}
|
||||
|
||||
stageOne := func(src, dest string) error {
|
||||
stagedDir := filepath.Join(stageRoot, filepath.Base(dest))
|
||||
if target.LinkCanonical {
|
||||
canonicalTarget := filepath.Join(target.CanonicalBase, filepath.Base(dest))
|
||||
if samePhysicalSkillSetupPath(dest, canonicalTarget) {
|
||||
return nil
|
||||
}
|
||||
realCanonicalTarget, realTargetErr := skillSetupEvalSymlinks(canonicalTarget)
|
||||
if realTargetErr != nil {
|
||||
return fmt.Errorf("解析 canonical Skill 失败 %s: %w", canonicalTarget, realTargetErr)
|
||||
}
|
||||
relTarget, relErr := skillSetupRel(realStageParent, realCanonicalTarget)
|
||||
if relErr != nil {
|
||||
return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr)
|
||||
}
|
||||
if linkErr := skillSetupSymlink(relTarget, stagedDir); linkErr != nil {
|
||||
return fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedDir, relTarget, linkErr)
|
||||
}
|
||||
staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest})
|
||||
return nil
|
||||
}
|
||||
if err := skillSetupMkdirAll(stagedDir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建 Skill staging 目录失败 %s: %w", stagedDir, err)
|
||||
}
|
||||
@@ -1675,16 +1982,11 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s
|
||||
|
||||
// restoreSkillSetupTarget removes a partially published replacement and
|
||||
// restores every original directory from its exact backup path.
|
||||
func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir) error {
|
||||
var restoreErr error
|
||||
for i := len(published) - 1; i >= 0; i-- {
|
||||
if err := skillSetupRemoveAll(published[i]); err != nil {
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err))
|
||||
}
|
||||
}
|
||||
func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error {
|
||||
restoreErr := skillSetupRollbackPaths(published)
|
||||
for i := len(backups) - 1; i >= 0; i-- {
|
||||
item := backups[i]
|
||||
if _, err := skillSetupStat(item.original); err == nil {
|
||||
if _, err := skillSetupLstat(item.original); err == nil {
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup))
|
||||
continue
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
@@ -1695,7 +1997,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup))
|
||||
continue
|
||||
}
|
||||
if err := skillSetupPublishRename(item.backup, item.original); err != nil {
|
||||
if err := skillSetupRestoreBackup(item.backup, item.original); err != nil {
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup))
|
||||
}
|
||||
}
|
||||
@@ -1728,45 +2030,53 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ
|
||||
}
|
||||
|
||||
func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error {
|
||||
published := make([]string, 0, len(staged))
|
||||
published := make([]upgrade.SkillPathPublication, 0, len(staged))
|
||||
for _, item := range staged {
|
||||
// Record before rename so rollback also removes a destination created by
|
||||
// a platform-specific partial failure.
|
||||
published = append(published, item.dest)
|
||||
if err := skillSetupPublishRename(item.staged, item.dest); err != nil {
|
||||
publication, err := skillSetupPublishPath(item.staged, item.dest)
|
||||
if err != nil {
|
||||
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err)
|
||||
if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil {
|
||||
return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr))
|
||||
}
|
||||
return publishErr
|
||||
}
|
||||
published = append(published, publication)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) {
|
||||
home, homeErr := skillSetupUserHomeDir()
|
||||
perTarget := 1
|
||||
if plan.Mode == skillSetupModeMulti {
|
||||
perTarget = len(plan.MultiSkillNames)
|
||||
hasCanonicalDependents := false
|
||||
for _, candidate := range plan.Targets {
|
||||
if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) {
|
||||
hasCanonicalDependents = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, target := range plan.Targets {
|
||||
perTarget := 1
|
||||
if plan.Mode == skillSetupModeMulti {
|
||||
perTarget = len(plan.MultiSkillNames)
|
||||
}
|
||||
isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase)
|
||||
if target.CleanupOnly {
|
||||
if skipped > 0 {
|
||||
continue
|
||||
}
|
||||
// Nothing is installed below a universal root, so a stale copy that
|
||||
// resists retirement must not count as a skipped install: any skipped
|
||||
// count fails the whole setup, even when every real target succeeded.
|
||||
if homeErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr)
|
||||
skipped++
|
||||
fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr)
|
||||
continue
|
||||
}
|
||||
if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr)
|
||||
skipped++
|
||||
fmt.Fprintf(errOut, " ⚠️ universal Agent 旧副本迁移失败,已回滚,可手动删除 %s: %v\n", target.Destination, cleanupErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(target.Backups) > 0 && homeErr != nil {
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
return installed, skipped + perTarget, fmt.Errorf("无法解析 HOME,canonical Skill 刷新中止 %s: %w", target.Destination, homeErr)
|
||||
}
|
||||
if plan.Mode == skillSetupModeMono {
|
||||
fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr)
|
||||
} else {
|
||||
@@ -1777,7 +2087,16 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
}
|
||||
|
||||
stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target)
|
||||
if stageErr != nil && target.LinkCanonical {
|
||||
fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination)
|
||||
fallback := target
|
||||
fallback.LinkCanonical = false
|
||||
stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback)
|
||||
}
|
||||
if stageErr != nil {
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr)
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr)
|
||||
skipped += perTarget
|
||||
continue
|
||||
@@ -1787,6 +2106,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
|
||||
backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr)
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr)
|
||||
skipped += perTarget
|
||||
continue
|
||||
@@ -1797,7 +2119,19 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
if cleanupErr != nil {
|
||||
publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) {
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %w", target.Destination, publishErr)
|
||||
}
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr)
|
||||
}
|
||||
if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) {
|
||||
// The destination may belong to a concurrent writer and was
|
||||
// deliberately retained; the rollback refuses to displace it.
|
||||
fmt.Fprintf(errOut, " ✗ Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %v\n", target.Destination, publishErr)
|
||||
} else {
|
||||
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
|
||||
}
|
||||
skipped += perTarget
|
||||
continue
|
||||
}
|
||||
@@ -1836,7 +2170,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[
|
||||
}
|
||||
var victims []string
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || keepSet[e.Name()] {
|
||||
if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] {
|
||||
continue
|
||||
}
|
||||
if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
)
|
||||
|
||||
func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
src := t.TempDir()
|
||||
skill := filepath.Join(src, "dingtalk-chat")
|
||||
if err := os.MkdirAll(skill, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skill, "SKILL.md"), []byte("chat"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := filepath.Join(home, ".agents", "skills")
|
||||
dependent := filepath.Join(home, ".claude", "skills")
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, dependent}, []string{"dingtalk-chat"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return home, plan
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t *testing.T) {
|
||||
t.Run("home", func(t *testing.T) {
|
||||
_, plan := canonicalFailurePlan(t)
|
||||
canonical := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
|
||||
if err := os.MkdirAll(canonical, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan.Targets[0].Backups = []skillSetupBackup{{Path: canonical, Reason: skillSetupBackupReplace}}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home denied") })
|
||||
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 刷新中止") {
|
||||
t.Fatalf("home failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backup", func(t *testing.T) {
|
||||
home, plan := canonicalFailurePlan(t)
|
||||
victim := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
|
||||
if err := os.MkdirAll(victim, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan.Targets[0].Backups = []skillSetupBackup{{Path: victim, Reason: skillSetupBackupReplace}}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup denied") })
|
||||
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 备份失败") {
|
||||
t.Fatalf("backup failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publish", func(t *testing.T) {
|
||||
home, plan := canonicalFailurePlan(t)
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
|
||||
return upgrade.SkillPathPublication{}, errors.New("publish denied")
|
||||
})
|
||||
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") {
|
||||
t.Fatalf("publish failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uncertain-publish", func(t *testing.T) {
|
||||
home, plan := canonicalFailurePlan(t)
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
|
||||
return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain)
|
||||
})
|
||||
errOut := &bytes.Buffer{}
|
||||
_, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut)
|
||||
if err == nil || !strings.Contains(err.Error(), "canonical Skill 发布状态不确定") {
|
||||
t.Fatalf("uncertain publish = %v", err)
|
||||
}
|
||||
// The destination was deliberately retained, so the canonical error
|
||||
// must not claim a rollback happened.
|
||||
if strings.Contains(err.Error(), "回滚") {
|
||||
t.Fatalf("uncertain error must not claim a rollback: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uncertain dependent target is retained and reported", func(t *testing.T) {
|
||||
home, plan := canonicalFailurePlan(t)
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
originalPublish := skillSetupPublishPath
|
||||
testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) {
|
||||
if strings.HasPrefix(destination, filepath.Join(home, ".claude")) {
|
||||
return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain)
|
||||
}
|
||||
return originalPublish(staged, destination)
|
||||
})
|
||||
errOut := &bytes.Buffer{}
|
||||
_, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut)
|
||||
if err != nil {
|
||||
t.Fatalf("dependent uncertain publish = %v", err)
|
||||
}
|
||||
if skipped != 1 {
|
||||
t.Fatalf("skipped = %d, want 1", skipped)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "发布状态不确定") || strings.Contains(errOut.String(), "已执行回滚") {
|
||||
t.Fatalf("errOut = %q, want retained-destination notice", errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) {
|
||||
home, plan := canonicalFailurePlan(t)
|
||||
canonicalTarget := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
|
||||
if err := os.MkdirAll(canonicalTarget, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(canonicalTarget, "SKILL.md"), []byte("chat"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked := plan.Targets[1]
|
||||
|
||||
t.Run("physical-parent", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) {
|
||||
if path == linked.Destination {
|
||||
return "", errors.New("parent denied")
|
||||
}
|
||||
return filepath.EvalSymlinks(path)
|
||||
})
|
||||
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "物理目录") {
|
||||
t.Fatalf("physical parent error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canonical-target", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) {
|
||||
if path == canonicalTarget {
|
||||
return "", errors.New("canonical denied")
|
||||
}
|
||||
return filepath.EvalSymlinks(path)
|
||||
})
|
||||
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "解析 canonical") {
|
||||
t.Fatalf("canonical target error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("relative-path", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupRel, func(string, string) (string, error) { return "", errors.New("relative denied") })
|
||||
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "相对链接") {
|
||||
t.Fatalf("relative path error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
_ = home
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupAgentHomes, []string{
|
||||
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
|
||||
})
|
||||
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
|
||||
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical := filepath.Join(home, ".agents", "skills")
|
||||
if len(dests) != 4 || dests[0] != canonical {
|
||||
t.Fatalf("targets = %v", dests)
|
||||
}
|
||||
|
||||
src := t.TempDir()
|
||||
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
|
||||
dir := filepath.Join(src, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
|
||||
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
claudeSkills := filepath.Join(home, ".claude", "skills")
|
||||
if err := os.MkdirAll(claudeSkills, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(claudeSkills, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("missing-target", filepath.Join(claudeSkills, "dingtalk-shared")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{})
|
||||
if err != nil || skipped != 0 || installed != 6 { // canonical + two linked Agents, two Skills each
|
||||
t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err)
|
||||
}
|
||||
if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
|
||||
t.Fatalf("Codex duplicate remains: %v", err)
|
||||
}
|
||||
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
|
||||
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("canonical %s missing: %v", name, err)
|
||||
}
|
||||
for _, agent := range []string{".claude", ".openclaw"} {
|
||||
link := filepath.Join(home, agent, "skills", name)
|
||||
info, err := os.Lstat(link)
|
||||
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("link %s = %#v, %v", link, info, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-running setup must recognize the existing links as already correct;
|
||||
// canonical refreshes in place without turning links into copied trees.
|
||||
plan, err = buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, agent := range []string{".claude", ".openclaw"} {
|
||||
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
|
||||
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("idempotent setup replaced %s link: %#v, %v", agent, info, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) {
|
||||
// Keep the destination HOME synthetic: app-bundle detection is deliberately
|
||||
// machine-scoped and must not depend on the selected installation HOME.
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" })
|
||||
for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
sentinel := filepath.Join(home, "app-sentinel")
|
||||
if err := os.MkdirAll(sentinel, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appInfo, err := os.Stat(sentinel)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")
|
||||
minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")
|
||||
originalStat := skillSetupStat
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if path == zcodeApp || path == minimaxApp {
|
||||
return appInfo, nil
|
||||
}
|
||||
return originalStat(path)
|
||||
})
|
||||
dests := detectExistingAgentHomes(home, skillSetupModeMulti)
|
||||
for _, target := range []string{
|
||||
filepath.Join(home, ".config", "kimchi", "harness", "skills"),
|
||||
filepath.Join(home, ".tabnine", "agent", "skills"),
|
||||
filepath.Join(home, ".zcode", "skills"),
|
||||
filepath.Join(home, ".minimax", "skills"),
|
||||
} {
|
||||
if !containsSkillName(dests, target) {
|
||||
t.Errorf("detected targets %v missing %s", dests, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
customClaude := filepath.Join(t.TempDir(), "claude")
|
||||
customCodex := filepath.Join(t.TempDir(), "codex")
|
||||
customHermes := filepath.Join(t.TempDir(), "hermes")
|
||||
for _, root := range []string{customClaude, customCodex, customHermes, filepath.Join(home, ".moltbot"), filepath.Join(home, ".copilot"), filepath.Join(home, ".config", "opencode"), filepath.Join(home, ".config", "agents"), filepath.Join(home, ".codeium", "windsurf")} {
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupGetenv, func(name string) string {
|
||||
switch name {
|
||||
case "CLAUDE_CONFIG_DIR":
|
||||
return customClaude
|
||||
case "CODEX_HOME":
|
||||
return customCodex
|
||||
case "HERMES_HOME":
|
||||
return customHermes
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
})
|
||||
|
||||
dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
filepath.Join(home, ".agents", "skills"),
|
||||
filepath.Join(customClaude, "skills"),
|
||||
filepath.Join(customCodex, "skills"),
|
||||
filepath.Join(customHermes, "skills"),
|
||||
filepath.Join(home, ".moltbot", "skills"),
|
||||
filepath.Join(home, ".copilot", "skills"),
|
||||
filepath.Join(home, ".config", "opencode", "skills"),
|
||||
filepath.Join(home, ".config", "agents", "skills"),
|
||||
filepath.Join(home, ".codeium", "windsurf", "skills"),
|
||||
} {
|
||||
found := false
|
||||
for _, got := range dests {
|
||||
if sameSkillSetupPath(got, want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("resolved targets %v missing %s", dests, want)
|
||||
}
|
||||
}
|
||||
if !isUniversalSkillSetupBase(filepath.Join(customCodex, "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "opencode", "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "agents", "skills")) {
|
||||
t.Fatal("custom Codex, OpenCode, and Amp must be universal cleanup-only targets")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCanonicalFailureStopsDependentTargets(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
canonical := filepath.Join(home, ".agents", "skills")
|
||||
claude := filepath.Join(home, ".claude", "skills")
|
||||
if err := os.MkdirAll(claude, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldClaude := filepath.Join(claude, "dingtalk-chat")
|
||||
if err := os.MkdirAll(oldClaude, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(oldClaude, "SKILL.md"), []byte("old remains"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(src, "dingtalk-chat"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(src, "dingtalk-chat", "SKILL.md"), []byte("new"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return errors.New("canonical copy denied") })
|
||||
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical") {
|
||||
t.Fatalf("canonical failure = %v", err)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(oldClaude, "SKILL.md"))
|
||||
if readErr != nil || string(body) != "old remains" {
|
||||
t.Fatalf("dependent Claude target changed: %q, %v", body, readErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCanonicalCopyFallbackMessage(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
canonical := filepath.Join(home, ".agents", "skills")
|
||||
claude := filepath.Join(home, ".claude", "skills")
|
||||
src := t.TempDir()
|
||||
skillSrc := filepath.Join(src, "dingtalk-chat")
|
||||
if err := os.MkdirAll(skillSrc, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupSymlink, func(string, string) error { return errors.New("links unavailable") })
|
||||
|
||||
plan, err := buildSkillSetupPlan(
|
||||
skillSetupModeMulti,
|
||||
src,
|
||||
[]string{canonical, claude},
|
||||
[]string{"dingtalk-chat"},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
|
||||
if err != nil || installed != 2 || skipped != 0 {
|
||||
t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err)
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "自动改用兼容安装") {
|
||||
t.Fatalf("human-readable fallback message missing: %s", errOut.String())
|
||||
}
|
||||
info, err := os.Lstat(filepath.Join(claude, "dingtalk-chat"))
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatalf("copy fallback = %#v, %v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil })
|
||||
testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" })
|
||||
|
||||
expected := map[string]string{
|
||||
"aider-desk": ".aider-desk/skills", "amp": ".config/agents/skills",
|
||||
"antigravity": ".gemini/antigravity/skills", "antigravity-cli": ".gemini/antigravity-cli/skills",
|
||||
"astrbot": ".astrbot/data/skills", "autohand-code": ".autohand/skills",
|
||||
"augment": ".augment/skills", "bob": ".bob/skills", "claude-code": ".claude/skills",
|
||||
"openclaw": ".openclaw/skills", "cline": ".agents/skills", "codearts-agent": ".codeartsdoer/skills",
|
||||
"codebuddy": ".codebuddy/skills", "codemaker": ".codemaker/skills", "codestudio": ".codestudio/skills",
|
||||
"codex": ".codex/skills", "command-code": ".commandcode/skills", "continue": ".continue/skills",
|
||||
"cortex": ".snowflake/cortex/skills", "crush": ".config/crush/skills", "cursor": ".cursor/skills",
|
||||
"deepagents": ".deepagents/agent/skills", "devin": ".config/devin/skills", "dexto": ".agents/skills",
|
||||
"droid": ".factory/skills", "firebender": ".firebender/skills", "forgecode": ".forge/skills",
|
||||
"gemini-cli": ".gemini/skills", "github-copilot": ".copilot/skills", "goose": ".config/goose/skills",
|
||||
"grok": ".grok/skills", "hermes-agent": ".hermes/skills", "inference-sh": ".inferencesh/skills",
|
||||
"jazz": ".jazz/skills", "junie": ".junie/skills", "iflow-cli": ".iflow/skills",
|
||||
"kilo": ".kilocode/skills", "kimchi": ".config/kimchi/harness/skills", "kimi-code-cli": ".agents/skills",
|
||||
"kiro-cli": ".kiro/skills", "kode": ".kode/skills", "lingma": ".lingma/skills", "loaf": ".agents/skills",
|
||||
"mcpjam": ".mcpjam/skills", "minimax-code": ".minimax/skills", "mistral-vibe": ".vibe/skills",
|
||||
"moxby": ".moxby/skills", "mux": ".mux/skills", "opencode": ".config/opencode/skills",
|
||||
"openhands": ".openhands/skills", "ona": ".ona/skills", "pi": ".pi/agent/skills",
|
||||
"qoder": ".qoder/skills", "qoder-cn": ".qoder-cn/skills", "qwen-code": ".qwen/skills",
|
||||
"replit": ".config/agents/skills", "reasonix": ".reasonix/skills", "rovodev": ".rovodev/skills",
|
||||
"roo": ".roo/skills", "tabnine-cli": ".tabnine/agent/skills", "terramind": ".terramind/skills",
|
||||
"tinycloud": ".tinycloud/skills", "trae": ".trae/skills", "trae-cn": ".trae-cn/skills",
|
||||
"universal": ".config/agents/skills", "warp": ".agents/skills", "windsurf": ".codeium/windsurf/skills",
|
||||
"zed": ".agents/skills", "zcode": ".zcode/skills", "zencoder": ".zencoder/skills",
|
||||
"zenflow": ".zencoder/skills", "neovate": ".neovate/skills", "pochi": ".pochi/skills", "adal": ".adal/skills",
|
||||
}
|
||||
if got := len(expected) + len(unsupportedGlobalAgentTargets); got != 76 {
|
||||
t.Fatalf("upstream agent enumeration = %d, want 76", got)
|
||||
}
|
||||
for target, rel := range expected {
|
||||
mapped, ok := agentSkillPaths[target]
|
||||
if !ok || filepath.Clean(mapped) != filepath.Clean(rel) {
|
||||
t.Errorf("agent %s map = %q, want %q", target, mapped, rel)
|
||||
}
|
||||
if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, filepath.Join(home, filepath.FromSlash(rel))) {
|
||||
t.Errorf("agent %s effective root = %q, want %q", target, got, filepath.Join(home, rel))
|
||||
}
|
||||
}
|
||||
for _, target := range []string{"eve", "promptscript"} {
|
||||
if _, err := resolveSkillSetupTargets(target, skillSetupModeMulti); err == nil {
|
||||
t.Errorf("%s unexpectedly resolved a global setup root", target)
|
||||
}
|
||||
if _, err := resolveSkillTargetPath(target); err == nil {
|
||||
t.Errorf("%s unexpectedly resolved a marketplace install root", target)
|
||||
}
|
||||
}
|
||||
if got := supportedTargets(); !strings.Contains(got, "eve") || !strings.Contains(got, "promptscript") {
|
||||
t.Fatalf("supported targets omit no-global upstream agents: %s", got)
|
||||
}
|
||||
|
||||
custom := map[string]string{
|
||||
"AUTOHAND_HOME": filepath.Join(home, "autohand-home"), "CLAUDE_CONFIG_DIR": filepath.Join(home, "claude-home"),
|
||||
"CODEX_HOME": filepath.Join(home, "codex-home"), "GROK_HOME": filepath.Join(home, "grok-home"),
|
||||
"HERMES_HOME": filepath.Join(home, "hermes-home"), "VIBE_HOME": filepath.Join(home, "vibe-home"),
|
||||
"XDG_CONFIG_HOME": filepath.Join(home, "xdg"),
|
||||
}
|
||||
testseam.Swap(t, &skillSetupGetenv, func(name string) string { return custom[name] })
|
||||
customCases := map[string]string{
|
||||
"autohand-code": filepath.Join(custom["AUTOHAND_HOME"], "skills"),
|
||||
"claude-code": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"),
|
||||
"codex": filepath.Join(custom["CODEX_HOME"], "skills"),
|
||||
"grok": filepath.Join(custom["GROK_HOME"], "skills"),
|
||||
"hermes-agent": filepath.Join(custom["HERMES_HOME"], "skills"),
|
||||
"mistral-vibe": filepath.Join(custom["VIBE_HOME"], "skills"),
|
||||
"amp": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
|
||||
"replit": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
|
||||
"universal": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
|
||||
"crush": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"),
|
||||
"devin": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"),
|
||||
"goose": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"),
|
||||
"kimchi": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"),
|
||||
"opencode": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"),
|
||||
}
|
||||
for target, want := range customCases {
|
||||
if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, want) {
|
||||
t.Errorf("custom %s root = %q, want %q", target, got, want)
|
||||
}
|
||||
}
|
||||
for _, target := range []string{"codex", "amp", "opencode"} {
|
||||
if !isUniversalSkillSetupBase(resolveSkillSetupBase(home, target)) {
|
||||
t.Errorf("custom %s root not classified universal", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOpenClawAliasPriority(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
dirs []string
|
||||
want string
|
||||
}{
|
||||
{name: "default", want: ".openclaw"},
|
||||
{name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"},
|
||||
{name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"},
|
||||
{name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
for _, dir := range tc.dirs {
|
||||
if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if got := resolveOpenClawSetupBase(home); got != filepath.Join(home, tc.want, "skills") {
|
||||
t.Fatalf("OpenClaw root = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupWindowsPathNormalization(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupFoldPathCase, true)
|
||||
if !sameSkillSetupPath(filepath.Join("Root", "Skills"), filepath.Join("root", "skills")) {
|
||||
t.Fatal("case-insensitive platform path normalization failed")
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
)
|
||||
|
||||
func useManagedSkillNames(t *testing.T, names ...string) {
|
||||
@@ -336,12 +337,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *test
|
||||
return originalBackup(homeDir, dir)
|
||||
})
|
||||
} else {
|
||||
originalRename := skillSetupPublishRename
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
|
||||
originalPublish := skillSetupPublishPath
|
||||
testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
|
||||
if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
|
||||
return failure
|
||||
return upgrade.SkillPathPublication{}, failure
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
return originalPublish(oldPath, newPath)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -432,8 +433,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
|
||||
t.Run("restore failure aggregation", func(t *testing.T) {
|
||||
t.Run("remove published", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure })
|
||||
if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) {
|
||||
testseam.Swap(t, &skillSetupRollbackPaths, func([]upgrade.SkillPathPublication) error { return failure })
|
||||
if err := restoreSkillSetupTarget([]upgrade.SkillPathPublication{{Destination: "published"}}, nil); !errors.Is(err, failure) {
|
||||
t.Fatalf("remove published error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -445,14 +446,14 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("stat", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
|
||||
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure })
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") {
|
||||
t.Fatalf("restore stat error = %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("mkdir", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure })
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") {
|
||||
@@ -460,9 +461,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
}
|
||||
})
|
||||
t.Run("rename", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
|
||||
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure })
|
||||
err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}})
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") {
|
||||
t.Fatalf("restore rename error = %v", err)
|
||||
@@ -479,10 +480,10 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
}
|
||||
return "", failure
|
||||
})
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
restoreErr := errors.New("restore failure")
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr })
|
||||
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return restoreErr })
|
||||
_, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard)
|
||||
if !errors.Is(err, failure) || !errors.Is(err, restoreErr) {
|
||||
t.Fatalf("backup rollback error = %v", err)
|
||||
@@ -490,8 +491,11 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("publish rollback failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure })
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
|
||||
return upgrade.SkillPathPublication{}, failure
|
||||
})
|
||||
testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure })
|
||||
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil })
|
||||
err := publishSkillSetupTarget(
|
||||
[]skillSetupStagedDir{{staged: "staged", dest: "dest"}},
|
||||
@@ -530,12 +534,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) {
|
||||
|
||||
t.Run("after publish failure", func(t *testing.T) {
|
||||
plan := newPlan(t)
|
||||
originalRename := skillSetupPublishRename
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error {
|
||||
originalPublish := skillSetupPublishPath
|
||||
testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) {
|
||||
if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") {
|
||||
return failure
|
||||
return upgrade.SkillPathPublication{}, failure
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
return originalPublish(oldPath, newPath)
|
||||
})
|
||||
originalRemoveAll := skillSetupRemoveAll
|
||||
cleanupErr := errors.New("cleanup after publish failure")
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies
|
||||
// pins the P0 fix: when a beta.6 folded dingtalk-misc (carrying the personal
|
||||
// Event route) exists only in a UNIVERSAL agent home (~/.codex/skills), the
|
||||
// Event/misc migration must not leave physical duplicates there. Universal
|
||||
// homes read ~/.agents/skills directly, so their old folded copies are retired
|
||||
// (backed up) and the split standalone event + clean misc land in canonical.
|
||||
func TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
|
||||
codexSkills := filepath.Join(home, ".codex", "skills")
|
||||
writeFoldedEventMisc(t, codexSkills)
|
||||
// beta.6 physical copies alongside the folded misc.
|
||||
for _, name := range []string{multiEventSkill, multiSharedSkill} {
|
||||
if err := os.MkdirAll(filepath.Join(codexSkills, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(codexSkills, name, "SKILL.md"), []byte("beta6 "+name+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
canonical := filepath.Join(home, ".agents", "skills")
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
|
||||
}
|
||||
|
||||
// P0: the universal home must not retain any physical dingtalk-* copy.
|
||||
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} {
|
||||
if _, err := os.Stat(filepath.Join(codexSkills, name)); !os.IsNotExist(err) {
|
||||
t.Fatalf("universal .codex/skills still has physical %s (stat err=%v)", name, err)
|
||||
}
|
||||
}
|
||||
// canonical owns the new standalone event + clean misc (+ shared).
|
||||
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} {
|
||||
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("canonical missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := validateCleanEventMiscRoot(filepath.Join(canonical, multiMiscSkill)); err != nil {
|
||||
t.Fatalf("canonical misc still contains folded Event content: %v", err)
|
||||
}
|
||||
if _, _, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes"); err != nil {
|
||||
t.Fatalf("idempotent rerun failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFoldedPair(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
canonical := filepath.Join(home, ".agents", "skills")
|
||||
codexSkills := filepath.Join(home, ".codex", "skills")
|
||||
writeFoldedEventMisc(t, codexSkills)
|
||||
writeOldStandaloneEvent(t, codexSkills)
|
||||
chat := filepath.Join(codexSkills, "dingtalk-chat")
|
||||
if err := os.MkdirAll(chat, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(chat, "SKILL.md"), []byte("keep chat\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc"})
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "doc", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("selective doc install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
}
|
||||
if strings.Contains(stdout, "Event 原子迁移") {
|
||||
t.Fatalf("unrelated selection migrated Event/misc: %s", stdout)
|
||||
}
|
||||
assertOldEventMiscPair(t, codexSkills)
|
||||
if body, err := os.ReadFile(filepath.Join(chat, "SKILL.md")); err != nil || string(body) != "keep chat\n" {
|
||||
t.Fatalf("unselected chat changed: body=%q err=%v", body, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T) {
|
||||
failure := errors.New("retirement denied")
|
||||
|
||||
t.Run("home", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
|
||||
codex := filepath.Join(t.TempDir(), ".codex", "skills")
|
||||
if err := retireMigratedUniversalSkills([]string{codex}, []string{multiEventSkill}, io.Discard); !errors.Is(err, failure) {
|
||||
t.Fatalf("home failure = %v, want %v", err, failure)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deduplicate", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
codex := filepath.Join(home, ".codex", "skills")
|
||||
if err := retireMigratedUniversalSkills(
|
||||
[]string{codex, codex},
|
||||
[]string{multiEventSkill, multiEventSkill},
|
||||
io.Discard,
|
||||
); err != nil {
|
||||
t.Fatalf("deduplicated retirement failed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Retiring an obsolete universal copy installs nothing, so its failure is
|
||||
// surfaced as a warning and the successful installation is kept.
|
||||
t.Run("backup failure warns without failing the command", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
||||
codex := filepath.Join(home, ".codex", "skills")
|
||||
writeFoldedEventMisc(t, codex)
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
|
||||
return "", failure
|
||||
})
|
||||
_, stderr, err := executeMultiSkillSetupTest(
|
||||
t,
|
||||
src,
|
||||
[]string{filepath.Join(home, ".agents", "skills"), codex},
|
||||
"--skill", "event", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("retirement backup failure must not fail the command: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr, "退役 universal Agent") {
|
||||
t.Fatalf("retirement backup failure must be reported, stderr = %q", stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -523,15 +523,45 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
|
||||
t.Run("ordinary install error", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
copyCalls := 0
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return fail })
|
||||
migration := filepath.Join(t.TempDir(), "migration")
|
||||
ordinary := filepath.Join(t.TempDir(), "ordinary")
|
||||
if _, _, err := installMultiSkillsWithEventMigration(src, []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "未执行迁移") {
|
||||
t.Fatalf("ordinary install failure = %v", err)
|
||||
}
|
||||
if copyCalls == 0 {
|
||||
t.Fatal("ordinary staging failure seam was not exercised")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("canonical ordinary install error", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
|
||||
return 0, 0, fail
|
||||
})
|
||||
home := t.TempDir()
|
||||
canonical := filepath.Join(home, ".agents", "skills")
|
||||
universalMigration := filepath.Join(home, ".codex", "skills")
|
||||
if _, _, err := installMultiSkillsWithEventMigration(
|
||||
"src",
|
||||
[]string{multiEventSkill},
|
||||
[]string{canonical, universalMigration},
|
||||
[]string{universalMigration},
|
||||
true,
|
||||
io.Discard,
|
||||
io.Discard,
|
||||
); !errors.Is(err, fail) {
|
||||
t.Fatalf("canonical ordinary install failure = %v, want %v", err, fail)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prerequisite install error", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) {
|
||||
return 0, 0, fail
|
||||
})
|
||||
migration := filepath.Join(t.TempDir(), "migration")
|
||||
ordinary := filepath.Join(t.TempDir(), "ordinary")
|
||||
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
t.Fatalf("ordinary install failure = %v", err)
|
||||
}
|
||||
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
t.Fatalf("prerequisite install failure = %v", err)
|
||||
}
|
||||
|
||||
@@ -48,16 +48,13 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t
|
||||
backupCalls, copyCalls := []string{}, 0
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
|
||||
backupCalls = append(backupCalls, path)
|
||||
if err := os.RemoveAll(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "backup", nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
|
||||
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
|
||||
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(src, dest)
|
||||
})
|
||||
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
|
||||
var dryRunOut bytes.Buffer
|
||||
dryRunCmd.SetOut(&dryRunOut)
|
||||
@@ -119,6 +116,9 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t
|
||||
|
||||
// A filtered multi plan replaces only selected same-name skills and leaves
|
||||
// unselected siblings out of the backup set.
|
||||
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -149,55 +149,38 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin
|
||||
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
dest := filepath.Join(home, ".codex", "skills")
|
||||
genericMono := filepath.Join(home, ".agents", "skills", "dws")
|
||||
if err := os.MkdirAll(genericMono, 0o755); err != nil {
|
||||
canonical := filepath.Join(home, ".agents", "skills")
|
||||
oldCodex := filepath.Join(dest, "dingtalk-chat")
|
||||
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
|
||||
return "", errors.New("transient HOME failure")
|
||||
})
|
||||
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
|
||||
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{canonical, dest}, []string{"dingtalk-chat"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
|
||||
t.Fatalf("generic cleanup target = %#v", plan.Targets)
|
||||
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest {
|
||||
t.Fatalf("universal cleanup target = %#v", plan.Targets)
|
||||
}
|
||||
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
|
||||
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
|
||||
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex {
|
||||
t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups)
|
||||
}
|
||||
var preview bytes.Buffer
|
||||
renderSkillSetupPlan(&preview, plan)
|
||||
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
|
||||
t.Fatalf("generic cleanup preview missing: %s", preview.String())
|
||||
if !strings.Contains(preview.String(), "改用统一安装位置") {
|
||||
t.Fatalf("universal cleanup preview missing: %s", preview.String())
|
||||
}
|
||||
|
||||
t.Run("managed multi and scan failure", func(t *testing.T) {
|
||||
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
|
||||
if err := os.MkdirAll(managedDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
|
||||
if targetErr != nil || target == nil || len(target.Backups) != 2 {
|
||||
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
|
||||
}
|
||||
failure := errors.New("generic scan failure")
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
|
||||
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
|
||||
t.Fatalf("generic scan error = %v", targetErr)
|
||||
}
|
||||
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
|
||||
t.Fatalf("generic cleanup plan error = %v", planErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
|
||||
failure := errors.New("cleanup failure")
|
||||
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
|
||||
|
||||
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
|
||||
t.Run("cleanup runs even after an install skip", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
|
||||
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
|
||||
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
|
||||
@@ -206,11 +189,14 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.
|
||||
}
|
||||
})
|
||||
|
||||
// A cleanup-only target installs nothing, so its failure is a warning and
|
||||
// must never increment skipped — runSkillSetup turns any skipped count into
|
||||
// a hard error and would fail an otherwise complete installation.
|
||||
t.Run("home failure keeps generic copy", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
|
||||
if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") {
|
||||
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
@@ -220,7 +206,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.
|
||||
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
|
||||
var stderr bytes.Buffer
|
||||
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
|
||||
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
|
||||
if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "迁移失败") {
|
||||
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
|
||||
}
|
||||
})
|
||||
@@ -247,11 +233,13 @@ func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testin
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
|
||||
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure })
|
||||
if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") {
|
||||
t.Fatalf("replacement stat error = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
|
||||
if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") {
|
||||
t.Fatalf("stale scan error = %v", err)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupRollbackRetainsConcurrentReplacement(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
base := filepath.Join(home, ".agents", "skills")
|
||||
first := filepath.Join(base, "dingtalk-first")
|
||||
second := filepath.Join(base, "dingtalk-second")
|
||||
writeSkillSetupFile(t, first, "old first")
|
||||
writeSkillSetupFile(t, second, "old second")
|
||||
backups, err := backupSkillSetupTarget(home, []skillSetupBackup{{Path: first}, {Path: second}}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stageRoot := filepath.Join(base, ".stage")
|
||||
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
|
||||
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
|
||||
writeSkillSetupFile(t, stagedFirst, "new first")
|
||||
writeSkillSetupFile(t, stagedSecond, "new second")
|
||||
|
||||
failure := errors.New("injected second setup publication failure")
|
||||
originalPublish := skillSetupPublishPath
|
||||
calls := 0
|
||||
testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
if err := os.RemoveAll(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeSkillSetupFile(t, first, "concurrent")
|
||||
return upgrade.SkillPathPublication{}, failure
|
||||
}
|
||||
return originalPublish(staged, destination)
|
||||
})
|
||||
|
||||
err = publishSkillSetupTarget([]skillSetupStagedDir{
|
||||
{staged: stagedFirst, dest: first},
|
||||
{staged: stagedSecond, dest: second},
|
||||
}, backups)
|
||||
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
|
||||
t.Fatalf("setup transaction error = %v", err)
|
||||
}
|
||||
assertSkillSetupFile(t, first, "concurrent")
|
||||
assertSkillSetupFile(t, second, "old second")
|
||||
var firstBackup string
|
||||
for _, item := range backups {
|
||||
if item.original == first {
|
||||
firstBackup = item.backup
|
||||
break
|
||||
}
|
||||
}
|
||||
if firstBackup == "" {
|
||||
t.Fatal("first backup was not recorded")
|
||||
}
|
||||
assertSkillSetupFile(t, firstBackup, "old first")
|
||||
}
|
||||
|
||||
func writeSkillSetupFile(t *testing.T, dir, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSkillSetupFile(t *testing.T, dir, want string) {
|
||||
t.Helper()
|
||||
content, err := os.ReadFile(filepath.Join(dir, "SKILL.md"))
|
||||
if err != nil || string(content) != want {
|
||||
t.Fatalf("Skill content at %s = %q, %v; want %q", dir, content, err, want)
|
||||
}
|
||||
}
|
||||
@@ -297,12 +297,12 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 dest, got %d", len(got))
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected canonical + Claude, got %d", len(got))
|
||||
}
|
||||
want := filepath.Join(home, ".claude", "skills", "dws")
|
||||
if filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[0])
|
||||
if filepath.Clean(got[1]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,12 +321,12 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 dest, got %d", len(got))
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected canonical + Claude, got %d", len(got))
|
||||
}
|
||||
want := filepath.Join(home, ".claude", "skills")
|
||||
if filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[0])
|
||||
if filepath.Clean(got[1]) != filepath.Clean(want) {
|
||||
t.Fatalf("expected %s, got %s", want, got[1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,8 +343,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".codex", "skills")
|
||||
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want [%s]", got, want)
|
||||
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want canonical + %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,8 +360,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".zcode", "skills")
|
||||
if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want [%s]", got, want)
|
||||
if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) {
|
||||
t.Fatalf("targets = %v, want canonical + %s", got, want)
|
||||
}
|
||||
|
||||
explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono)
|
||||
@@ -369,8 +369,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T)
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantMono := filepath.Join(want, "dws")
|
||||
if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) {
|
||||
t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono)
|
||||
if len(explicit) != 2 || filepath.Clean(explicit[1]) != filepath.Clean(wantMono) {
|
||||
t.Fatalf("explicit zcode targets = %v, want canonical + %s", explicit, wantMono)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1055,12 +1055,11 @@ func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets(
|
||||
if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "重新加载 Skills") {
|
||||
if !strings.Contains(stdout, "请重启已打开的 Agent") {
|
||||
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
|
||||
}
|
||||
|
||||
|
||||
@@ -620,6 +620,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error {
|
||||
for _, d := range succeeded {
|
||||
fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir)))
|
||||
}
|
||||
for _, d := range result.RetireWarnings() {
|
||||
fmt.Printf(" %s %s %s\n", ugYellow("⚠"), shortenHome(d.Dir), ugDim("旧副本未能迁移,可手动删除: "+d.Err.Error()))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf(" %s\n", ugGreen("✓"))
|
||||
}
|
||||
|
||||
@@ -292,6 +292,12 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
|
||||
if stage == "install-failed-dir" {
|
||||
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil
|
||||
}
|
||||
if stage == "install-retire-warning" {
|
||||
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{
|
||||
{Dir: "/ok", Status: upgradepkg.SkillDirOK},
|
||||
{Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")},
|
||||
}}, nil
|
||||
}
|
||||
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil
|
||||
}
|
||||
}
|
||||
@@ -300,7 +306,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
|
||||
"ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both",
|
||||
"backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills",
|
||||
"extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir",
|
||||
"success", "success-no-skills",
|
||||
"success", "success-no-skills", "install-retire-warning",
|
||||
} {
|
||||
t.Run(stage, func(t *testing.T) {
|
||||
configure(stage)
|
||||
@@ -330,14 +336,14 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
|
||||
opts.skipSkills = true
|
||||
}
|
||||
err := runUpgrade(context.Background(), opts)
|
||||
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills"
|
||||
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning"
|
||||
if wantError && err == nil {
|
||||
t.Fatalf("stage %s succeeded", stage)
|
||||
}
|
||||
if !wantError && err != nil {
|
||||
t.Fatalf("stage %s failed: %v", stage, err)
|
||||
}
|
||||
if (stage == "success" || stage == "success-no-skills") && !rb.cleaned {
|
||||
if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned {
|
||||
t.Fatal("successful upgrade did not clean backups")
|
||||
}
|
||||
if stage == "success" {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
for canonical, command := range map[string]string{
|
||||
"whiteboard.shortcut_query": "+query",
|
||||
"whiteboard.shortcut_update": "+update",
|
||||
} {
|
||||
leaf, _, err := root.Find([]string{"whiteboard", command})
|
||||
if err != nil || leaf == nil || leaf.Name() != command {
|
||||
t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err)
|
||||
} else if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("public %s missing from delivery Schema surface", canonical)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("%s availability=%q, want available", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("%s interface_mode=%q, want composite", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("%s missing composite adapter reason", canonical)
|
||||
}
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
@@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
@@ -811,7 +811,84 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("refreshing token (dual-locked)")
|
||||
}
|
||||
return oauthRefreshToken(p, ctx, data)
|
||||
refreshed, rErr := oauthRefreshToken(p, ctx, data)
|
||||
if rErr == nil || !isRefreshTokenRejected(rErr) {
|
||||
return refreshed, rErr
|
||||
}
|
||||
// A stale identity slot can survive an older organization-only refresh.
|
||||
// Retry once with the same-corp organization mirror while holding the
|
||||
// existing dual lock; the fallback marks the publication so the rotated
|
||||
// credential is written back into the mirror slot it consumed.
|
||||
logging.AuthDebug(
|
||||
"auth.refresh.fallback.triggered",
|
||||
"corp_id", strings.TrimSpace(data.CorpID),
|
||||
"user_id", strings.TrimSpace(data.UserID),
|
||||
"error", rErr,
|
||||
)
|
||||
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
|
||||
if fErr != nil {
|
||||
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
|
||||
return nil, rErr
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态"))
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// refreshFromOrgSlot retries a rejected refresh with the token mirrored in
|
||||
// the organization slot. The mirror must match the current corp, be valid,
|
||||
// and differ from the rejected token. When both slots carry user identities,
|
||||
// they must agree; legacy mirrors with an empty UserID are backfilled from the
|
||||
// current identity before refresh.
|
||||
func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) {
|
||||
if current == nil {
|
||||
return nil, fmt.Errorf("no current token data")
|
||||
}
|
||||
corpID := strings.TrimSpace(current.CorpID)
|
||||
if corpID == "" {
|
||||
return nil, fmt.Errorf("current token has no corpId")
|
||||
}
|
||||
orgData, err := tokenLoadKeychainForCorpID(corpID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgData == nil {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
if strings.TrimSpace(orgData.CorpID) != corpID {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID)
|
||||
}
|
||||
if !orgData.IsRefreshTokenValid() {
|
||||
return nil, fmt.Errorf("organization mirror refresh_token 已过期")
|
||||
}
|
||||
if orgData.RefreshToken == current.RefreshToken {
|
||||
return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token")
|
||||
}
|
||||
currentUserID := strings.TrimSpace(current.UserID)
|
||||
orgUserID := strings.TrimSpace(orgData.UserID)
|
||||
if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID)
|
||||
}
|
||||
if orgUserID == "" {
|
||||
orgData.UserID = current.UserID
|
||||
orgData.UserName = current.UserName
|
||||
}
|
||||
// The refresh below consumes the mirror's refresh_token. Mark the
|
||||
// publication so persistence writes the rotated credential back into the
|
||||
// organization slot even under an explicit runtime selector whose plan
|
||||
// would otherwise skip it (for example a preserved unresolved sibling).
|
||||
orgData.RepairOrganizationMirror = true
|
||||
refreshed, err := oauthRefreshToken(p, ctx, orgData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.refresh.fallback.success",
|
||||
"corp_id", corpID,
|
||||
"new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339),
|
||||
)
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// 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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true},
|
||||
{"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false},
|
||||
{"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true},
|
||||
{"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false},
|
||||
{"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false},
|
||||
{"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false},
|
||||
{"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false},
|
||||
{"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false},
|
||||
{"plain error is unknown", errors.New("boom"), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isRefreshTokenRejected(tt.err); got != tt.want {
|
||||
t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on
|
||||
// and records refresh attempts plus organization slot lookups.
|
||||
type orgSlotFallbackFixture struct {
|
||||
provider *OAuthProvider
|
||||
stale *TokenData
|
||||
orgMirror *TokenData
|
||||
renewed *TokenData
|
||||
rejected *MCPTokenExchangeError
|
||||
refreshErr error
|
||||
orgRefreshErr error
|
||||
refreshCalls []string
|
||||
refreshUserIDs []string
|
||||
orgLoads int
|
||||
}
|
||||
|
||||
func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture {
|
||||
t.Helper()
|
||||
isolateOAuthPersistence(t)
|
||||
|
||||
f := &orgSlotFallbackFixture{
|
||||
provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard},
|
||||
stale: &TokenData{
|
||||
AccessToken: "old-access",
|
||||
RefreshToken: "stale-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
orgMirror: &TokenData{
|
||||
AccessToken: "org-access",
|
||||
RefreshToken: "org-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
renewed: &TokenData{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"},
|
||||
}
|
||||
f.refreshErr = f.rejected
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) })
|
||||
testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||
f.refreshCalls = append(f.refreshCalls, data.RefreshToken)
|
||||
f.refreshUserIDs = append(f.refreshUserIDs, data.UserID)
|
||||
switch data.RefreshToken {
|
||||
case "stale-refresh":
|
||||
return nil, f.refreshErr
|
||||
case "org-refresh":
|
||||
return f.renewed, f.orgRefreshErr
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken)
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
if corpID != "corp-1" {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
return f.orgMirror, nil
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
|
||||
got, err := f.provider.lockedRefresh(context.Background())
|
||||
if err != nil || got != f.renewed {
|
||||
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" {
|
||||
t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls)
|
||||
}
|
||||
if f.orgLoads != 1 {
|
||||
t.Fatalf("organization slot loads = %d, want 1", f.orgLoads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) {
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "legacy", CorpID: "corp-1", UserID: ""},
|
||||
{Name: "user-1", CorpID: "corp-1", UserID: "user-1"},
|
||||
},
|
||||
}
|
||||
selector := profileSelector("corp-1", "user-1")
|
||||
|
||||
without := &TokenData{CorpID: "corp-1", UserID: "user-1"}
|
||||
if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization {
|
||||
t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false")
|
||||
}
|
||||
|
||||
with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true}
|
||||
if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization {
|
||||
t.Fatalf("repair marker did not force the organization slot write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
// Fake MCP refresh endpoint: the first call rejects the stale identity
|
||||
// refresh_token with the reviewed business code; the second call (the
|
||||
// organization mirror) succeeds and returns a rotated credential.
|
||||
var refreshCalls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if refreshCalls.Add(1) == 1 {
|
||||
fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
resetAppConfigCache()
|
||||
|
||||
// Seed the pre-fallback state: an identity slot whose refresh_token the
|
||||
// server rejects, plus a legacy organization mirror (no userId) with a
|
||||
// still-valid refresh_token and a preserved unresolved sibling profile so
|
||||
// an explicit --profile refresh would normally skip the org slot.
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"},
|
||||
{Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"},
|
||||
},
|
||||
}
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
orgMirror := &TokenData{
|
||||
AccessToken: "org-access",
|
||||
RefreshToken: "org-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
Source: "mcp",
|
||||
ClientID: "mcp-client",
|
||||
}
|
||||
if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
staleIdentity := &TokenData{
|
||||
AccessToken: "stale-access",
|
||||
RefreshToken: "stale-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
UserName: "User One",
|
||||
Source: "mcp",
|
||||
ClientID: "mcp-client",
|
||||
}
|
||||
if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("corp-1:user-1")
|
||||
t.Cleanup(func() { SetRuntimeProfile("") })
|
||||
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
got, err := p.lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("lockedRefresh() = %#v, want rotated credential", got)
|
||||
}
|
||||
if refreshCalls.Load() != 2 {
|
||||
t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load())
|
||||
}
|
||||
|
||||
// The fallback consumed the mirror's refresh_token: both persisted slots
|
||||
// must now carry the rotated credential instead of the consumed one.
|
||||
orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" {
|
||||
t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot)
|
||||
}
|
||||
identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
if identitySlot.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("identity slot = %#v, want new-refresh", identitySlot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) {
|
||||
t.Run("transient failure does not fall back", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError}
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if err == nil || err.Error() != f.refreshErr.Error() {
|
||||
t.Fatalf("lockedRefresh() error = %v, want transient failure", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing org slot preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
return nil, ErrTokenDataNotFound
|
||||
})
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil org data from keychain preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org slot refresh failure preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgRefreshErr = fmt.Errorf("org mirror refresh failed")
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 {
|
||||
t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different user in org slot is rejected", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.UserID = "user-2"
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty org slot user identity is backfilled", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.UserID = ""
|
||||
f.orgMirror.UserName = ""
|
||||
|
||||
got, err := f.provider.lockedRefresh(context.Background())
|
||||
if err != nil || got != f.renewed {
|
||||
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 {
|
||||
t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls)
|
||||
}
|
||||
if f.refreshUserIDs[1] != "user-1" {
|
||||
t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same rejected refresh token is skipped", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.RefreshToken = "stale-refresh"
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired org refresh token is skipped", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour)
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing corp id skips fallback", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.stale.CorpID = ""
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest}
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if err == nil || err.Error() != f.refreshErr.Error() {
|
||||
t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
|
||||
if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil {
|
||||
t.Fatal("refreshFromOrgSlot(nil) succeeded")
|
||||
}
|
||||
f.orgMirror.CorpID = "corp-2"
|
||||
if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil {
|
||||
t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded")
|
||||
}
|
||||
if len(f.refreshCalls) != 0 {
|
||||
t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls)
|
||||
}
|
||||
}
|
||||
@@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass {
|
||||
}
|
||||
return RefreshFailureUnknown
|
||||
}
|
||||
|
||||
// isRefreshTokenRejected reports whether the server returned a reviewed
|
||||
// business code that definitively rejects the presented refresh_token.
|
||||
// Only the reviewed MCP business code enables the organization-slot
|
||||
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
|
||||
// reviewed business code and deliberately do not trigger the fallback.
|
||||
func isRefreshTokenRejected(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
|
||||
}
|
||||
+16
-1
@@ -107,6 +107,13 @@ type TokenData struct {
|
||||
// transient marker to reject ambiguous UID-less logins without breaking
|
||||
// legitimate refreshes of unresolved accounts.
|
||||
FreshAuthorization bool `json:"-"`
|
||||
// RepairOrganizationMirror marks a fallback refresh that consumed the
|
||||
// organization mirror's refresh_token. The regular write plan can skip the
|
||||
// organization slot under an explicit runtime selector (for example when an
|
||||
// unresolved sibling profile still owns it), which would strand a
|
||||
// refresh_token the server has already rotated; the marker forces the
|
||||
// rotated credential back into that slot.
|
||||
RepairOrganizationMirror bool `json:"-"`
|
||||
}
|
||||
|
||||
// tokenPersistenceWritePlan is the single source of truth for deciding which
|
||||
@@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct {
|
||||
ExistingIdentity bool
|
||||
UpgradesLegacyProfile bool
|
||||
PreserveUnresolvedOrganization bool
|
||||
RepairOrganizationMirror bool
|
||||
WriteIdentity bool
|
||||
WriteOrganization bool
|
||||
WriteGlobal bool
|
||||
@@ -167,6 +175,11 @@ func planTokenPersistenceWrites(
|
||||
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
|
||||
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
|
||||
!plan.UpgradesLegacyProfile
|
||||
// A fallback refresh consumed the organization mirror's refresh_token;
|
||||
// the rotated credential must go back into that slot even when the
|
||||
// selector-driven plan would skip it (for example an explicit --profile
|
||||
// that preserves an unresolved sibling profile's slot).
|
||||
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
plan.WriteIdentity = plan.UserID != ""
|
||||
orgCurrentSelector := ""
|
||||
if cfg != nil {
|
||||
@@ -177,7 +190,8 @@ func planTokenPersistenceWrites(
|
||||
// reauthorization. Its organization slot must move with the newly exact
|
||||
// identity even when an explicit runtime selector keeps it from becoming
|
||||
// process-global current.
|
||||
plan.WriteOrganization = plan.UserID == "" ||
|
||||
plan.WriteOrganization = plan.RepairOrganizationMirror ||
|
||||
plan.UserID == "" ||
|
||||
plan.UpgradesLegacyProfile ||
|
||||
(!plan.PreserveUnresolvedOrganization &&
|
||||
(plan.MakeCurrent ||
|
||||
@@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
"persistence_profile", plan.PersistenceSelector,
|
||||
"write_identity_slot", plan.WriteIdentity,
|
||||
"write_org_mirror", plan.WriteOrganization,
|
||||
"repair_org_mirror", plan.RepairOrganizationMirror,
|
||||
"write_global_mirror", plan.WriteGlobal,
|
||||
"publish_incoming_global", plan.MakeCurrent,
|
||||
)
|
||||
|
||||
@@ -191,12 +191,12 @@
|
||||
"from": "oa +list-processes",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"oa +list-forms",
|
||||
"oa +my-initiated",
|
||||
"oa +search-forms",
|
||||
"oa approval list-submitted",
|
||||
"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."
|
||||
"review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-detail",
|
||||
|
||||
@@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{
|
||||
{
|
||||
From: "oa +list-processes",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
Candidates: []string{"oa +search-forms", "oa approval list-submitted", "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.",
|
||||
ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
|
||||
"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"},
|
||||
"oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "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"},
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,16 +10,16 @@
|
||||
},
|
||||
|
||||
"concepts": {
|
||||
"search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms"],"risk":"green"},
|
||||
"pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","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":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list"],"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"},
|
||||
"search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search"],"risk":"green"},
|
||||
"pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"risk":"green"},
|
||||
"page_number": {"denotes":"one-based page number","canonical_hint":"page","members":["page","page-no","current-page","page-num"],"excludes":["cursor","page-index","page-size","page-token"],"commands":["devdoc article search","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list"],"risk":"green"},
|
||||
"page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event","minutes +detail","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","wiki +feed-list","wiki +node-search","wiki feed list","wiki node list","wiki node search","wiki space list"],"risk":"green"},
|
||||
"content_text": {"denotes":"text body content","canonical_hint":"text","members":["text","content","body"],"excludes":["title","name"],"commands":["doc +checkpoint-update","doc +comment-create","doc +comment-reply","doc +comment-update","doc +create","doc +doc-append","doc block insert","doc block update","doc comment create","doc comment create-inline","doc comment reply","doc comment update","doc create","minutes +summary","minutes update summary","todo +comment","todo comment add"],"risk":"green"},
|
||||
"time_start": {"denotes":"start time point with unchanged value format and unit","canonical_hint":"start","members":["start","start-time","start-date","from","from-date","begin","since","time-min","min-time"],"excludes":["date","time","end"],"commands":["calendar event list","chat message list-all","report list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared"],"risk":"yellow"},
|
||||
"time_end": {"denotes":"end time point with unchanged value format and unit","canonical_hint":"end","members":["end","end-time","end-date","time-max","max-time"],"excludes":["date","time","start"],"commands":["calendar event list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared"],"risk":"yellow"},
|
||||
"base_id": {"denotes":"multi-dimensional table Base id","canonical_hint":"base-id","members":["base","base-id","base-token"],"excludes":[],"commands":["aitable +advperm-disable","aitable +advperm-enable","aitable +attachment-put","aitable +attachment-remove","aitable +attachment-upload","aitable +base-copy","aitable +base-delete","aitable +base-get","aitable +base-get-primary-doc-id","aitable +base-schema-snapshot","aitable +base-update","aitable +chart-delete","aitable +chart-get","aitable +chart-share-get","aitable +chart-share-update","aitable +chart-update","aitable +dashboard-arrange","aitable +dashboard-delete","aitable +dashboard-get","aitable +dashboard-share-get","aitable +dashboard-share-update","aitable +dashboard-update","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-upload","aitable +list-tables","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +resolve-table","aitable +role-create","aitable +role-delete","aitable +role-get","aitable +role-list","aitable +role-update","aitable +section-create","aitable +section-delete","aitable +section-list-empty","aitable +section-list-nodes","aitable +section-move-node","aitable +section-rename","aitable +section-reorder","aitable +table-delete","aitable +table-get","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable +workflow-list","aitable advperm disable","aitable advperm enable","aitable advperm role-create","aitable advperm role-delete","aitable advperm role-get","aitable advperm role-list","aitable advperm role-update","aitable attachment upload","aitable base copy","aitable base delete","aitable base get","aitable base get-primary-doc-id","aitable base update","aitable chart create","aitable chart delete","aitable chart get","aitable chart share get","aitable chart share update","aitable chart update","aitable dashboard arrange","aitable dashboard create","aitable dashboard delete","aitable dashboard get","aitable dashboard share get","aitable dashboard share update","aitable dashboard update","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import upload","aitable info","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable section create","aitable section delete","aitable section list-empty","aitable section list-nodes","aitable section move-node","aitable section rename","aitable section reorder","aitable table create","aitable table delete","aitable table get","aitable table list","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow create","aitable workfloLine truncated
|
||||
"table_id": {"denotes":"single AI Table data-table Table ID","canonical_hint":"table-id","members":["table","table-id"],"excludes":["table-ids","source-table-id","target-table-id","name"],"commands":["aitable +attachment-put","aitable +attachment-remove","aitable +base-get-primary-doc-id","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-data","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +table-delete","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable base get-primary-doc-id","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import data","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable table delete","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow run"],"risk":"green"},
|
||||
"plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update"],"risk":"green"},
|
||||
"plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update","calendar +create","calendar +update","wiki +space-create","wiki space create"],"risk":"green"},
|
||||
"workflow_id": {"denotes":"single AI Table workflow identifier; service flowId and CLI workflow-id carry the same value","canonical_hint":"workflow-id","members":["workflow-id","flow-id"],"excludes":["id","task-id"],"commands":["aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow run","aitable workflow update"],"risk":"green"},
|
||||
"dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"},
|
||||
"dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"},
|
||||
@@ -31,7 +31,7 @@
|
||||
"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 +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"},
|
||||
"user_ids": {"denotes":"user id list","canonical_hint":"user-ids","members":["users","user-ids"],"excludes":["user","user-id","userid","uid","staff-id","at-user-ids"],"commands":["attendance +check-result","attendance check result","chat +messages-batch-send-by-bot","chat group members remove","chat group set-admin","chat group-mute-member","chat message read-status","chat message search-advanced","chat message send-by-bot","calendar +freebusy","calendar +suggestion","minutes +share","minutes +unshare","minutes permission add","minutes permission remove","wiki +member-add","wiki +member-remove","wiki +member-update"],"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 +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"},
|
||||
@@ -49,12 +49,29 @@
|
||||
"drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"},
|
||||
"drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"},
|
||||
"drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"},
|
||||
"workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "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 +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"},
|
||||
"workspace_id": {"denotes":"single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID","canonical_hint":"workspace","members":["workspace","workspace-id","knowledge-base-id","wiki-workspace-id"],"excludes":["space","space-id","drive-space-id","storage-space-id","dingdrive-space-id","folder","folder-id","node","node-id","dentry-id"],"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 +copy","drive +create-shortcut","drive +move","drive copy","drive list","drive move","drive permission add","drive permission list","drive permission remove","drive permission transfer-owner","drive permission update","drive shortcut","drive upload","wiki +delete-space","wiki +feed-list","wiki +member-add","wiki +member-list","wiki +member-remove","wiki +member-update","wiki +move","wiki +node-copy","wiki +node-create","wiki +node-delete","wiki +node-list","wiki +node-search","wiki +space-get","wiki feed list","wiki member add","wiki member list","wiki member remove","wiki member update","wiki node copy","wiki node create","wiki node delete","wiki node list","wiki node move","wiki node search","wiki space delete","wiki space get"],"risk":"yellow"},
|
||||
"created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"},
|
||||
"creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"},
|
||||
"local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"}
|
||||
"local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"},
|
||||
"calendar_event_id": {"denotes":"single calendar eventId in the target-event role","canonical_hint":"event","members":["event","event-id","calendar-event-id"],"excludes":["id","calendar-id","calendar-book-id","acl-id","room-id"],"commands":["calendar +attendee-list","calendar +cancel-event","calendar +invite","calendar +reschedule","calendar +get","calendar +rsvp","calendar +update"],"risk":"yellow"},
|
||||
"calendar_book_id": {"denotes":"single calendar container calendarId, not an eventId","canonical_hint":"calendar-id","members":["calendar-id","calendar","calendar-book-id"],"excludes":["id","event","event-id","acl-id","room-id"],"commands":["calendar +agenda","calendar +attendee-list","calendar +create","calendar +get","calendar +rsvp","calendar +search-event","calendar +update"],"risk":"yellow"},
|
||||
"calendar_event_title": {"denotes":"calendar event title text","canonical_hint":"title","members":["title","summary","subject"],"excludes":["name","room-name","calendar-name"],"commands":["calendar +book","calendar +create","calendar +update"],"risk":"green"},
|
||||
"calendar_person_name_list": {"denotes":"comma-separated DingTalk person names resolved by the shortcut","canonical_hint":"with","members":["with","names","attendee-names","participant-names"],"excludes":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids"],"commands":["calendar +book","calendar +invite","calendar +suggest-time"],"risk":"yellow"},
|
||||
"calendar_room_ids": {"denotes":"calendar meeting roomId list with unchanged element values; one roomId is a one-element list","canonical_hint":"rooms","members":["rooms","room-id","room-ids"],"excludes":["room","room-name","group-id","location"],"commands":["calendar event create","calendar room add","calendar room delete","calendar busy search","calendar +create","calendar +freebusy"],"risk":"yellow"},
|
||||
"calendar_duration_minutes": {"denotes":"meeting duration in minutes with unchanged numeric text","canonical_hint":"duration","members":["duration","duration-minutes","meeting-duration-minutes"],"excludes":["remind-minutes","start","end","from","to"],"commands":["calendar event suggest","calendar +suggest-time","calendar +suggestion"],"risk":"green"},
|
||||
"calendar_timezone": {"denotes":"IANA timezone string with unchanged value","canonical_hint":"timezone","members":["timezone","time-zone","tz"],"excludes":["offset","utc-offset","locale"],"commands":["calendar event create","calendar event suggest","calendar event update","calendar +create","calendar +suggestion","calendar +update"],"risk":"green"},
|
||||
"minutes_task_uuid": {"denotes":"single DingTalk Minutes taskUuid with unchanged value","canonical_hint":"task-uuid","members":["task-uuid","uuid"],"excludes":["ids","task-uuids","uuids","url","minutes-id","meeting-id","record-id","task-id"],"commands":["minutes +action-items","minutes +apply-permission","minutes +detail","minutes +download","minutes +export-pack","minutes +mindmap","minutes +record-pause","minutes +record-resume","minutes +record-stop","minutes +record-wrap-up","minutes +replace-batch","minutes +share","minutes +speaker-insights","minutes +speaker-replace","minutes +summary","minutes +transcript","minutes +unshare","minutes +update","minutes record pause","minutes record resume","minutes record stop","minutes replace-text","minutes speaker replace","minutes update summary","minutes update title"],"risk":"yellow"},
|
||||
"minutes_task_uuids": {"denotes":"Minutes taskUuid list with unchanged element values and unchanged list encoding","canonical_hint":"ids","members":["ids","task-uuids","uuids"],"excludes":["id","task-uuid","uuid","url","minutes-id","minutes-ids","meeting-id","meeting-ids"],"commands":["minutes +detail","minutes +download","minutes +share","minutes +unshare","minutes get batch","minutes permission add","minutes permission remove","minutes speaker summary create","minutes speaker summary get"],"risk":"yellow"},
|
||||
"minutes_hot_words": {"denotes":"personal hot-word list with unchanged values and unchanged list encoding","canonical_hint":"words","members":["words","hot-words","hotwords"],"excludes":["word","hot-word","query","keyword","keywords","search"],"commands":["minutes +prepare-asr","minutes hot-word add","minutes hot-word delete"],"risk":"yellow"},
|
||||
"minutes_upload_session_id": {"denotes":"single Minutes file-upload sessionId with unchanged value","canonical_hint":"session-id","members":["session-id","upload-session-id","upload-id"],"excludes":["id","task-id","task-uuid","minutes-id","recording-session-id"],"commands":["minutes upload cancel","minutes upload complete"],"risk":"yellow"},
|
||||
"todo_task_id": {"denotes":"single DingTalk todo taskId with unchanged value","canonical_hint":"task-id","members":["task-id","todo-id"],"excludes":["id","ids","task","task-ids","todo-ids","parent-id","comment-id","attachment-id","open-task-id"],"commands":["todo +comment","todo +complete","todo +get","todo +list-attachment","todo +list-comment","todo +list-sub","todo +reminder","todo +reopen","todo +update","todo comment add","todo comment delete","todo comment list","todo tag add","todo task add-attachment","todo task add-executor","todo task add-participant","todo task add-reminder","todo task create-sub","todo task delete","todo task done","todo task get","todo task list-attachment","todo task list-sub","todo task remove-attachment","todo task remove-executor","todo task remove-participant","todo task reset-reminder","todo task update"],"risk":"yellow"},
|
||||
"todo_due_time": {"denotes":"todo deadline ISO-8601 value, not an independent reminder time","canonical_hint":"due","members":["due","due-time","deadline"],"excludes":["remind-at","reminder-at","reminder-time","reminder-time-stamp","due-date-offset","plan-finish-start","plan-finish-end","plan-finish-date-start","plan-finish-date-end"],"commands":["todo +assign","todo +create","todo +update","todo task create","todo task create-sub","todo task update"],"risk":"yellow"},
|
||||
"todo_executor_ids": {"denotes":"todo executor userId list with unchanged comma-separated value","canonical_hint":"executors","members":["executors","executor-ids"],"excludes":["executor","executor-id","participant","participant-id","participants","participant-ids","to","user","user-id","user-ids","users","name"],"commands":["todo +create","todo task add-executor","todo task create","todo task create-sub","todo task remove-executor"],"risk":"yellow"},
|
||||
"todo_participant_ids": {"denotes":"todo participant userId list with unchanged comma-separated value","canonical_hint":"participants","members":["participants","participant-ids"],"excludes":["participant","participant-id","executor","executor-id","executors","executor-ids","to","user","user-id","user-ids","users","name"],"commands":["todo task add-participant","todo task remove-participant"],"risk":"yellow"},
|
||||
"todo_completion_state": {"denotes":"todo completion boolean string true/false with unchanged value","canonical_hint":"status","members":["status","done","is-done","todo-status"],"excludes":["state","task-status"],"commands":["todo +get-my-tasks","todo +search","todo task done","todo task list","todo task update"],"risk":"yellow"},
|
||||
"todo_role_types": {"denotes":"todo creator/executor/participant role filter list with unchanged comma-separated value","canonical_hint":"role-types","members":["role-types","roles"],"excludes":["role","role-id","role-ids"],"commands":["todo +due-today","todo +get-my-tasks","todo +get-related-tasks","todo task list"],"risk":"yellow"}
|
||||
},
|
||||
|
||||
"command_overrides": {
|
||||
@@ -82,6 +99,40 @@
|
||||
"mail folder update": {"bind": {"id": "folder_id"}, "note": "this command's --id is the folder id; --folder-id reduces to --id"},
|
||||
"mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"},
|
||||
"calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"},
|
||||
"calendar +agenda": {"block":["id"],"note":"calendarId aliases stay role-specific; bare --id could be an eventId and must not be silently treated as a calendarId. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end"},"scope_strict":true},
|
||||
"calendar +attendee-list": {"ambiguous":["id"],"note":"This command carries both an eventId and an optional calendarId; bare --id cannot safely choose the role."},
|
||||
"calendar +book": {"block":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids","calendar-id","rooms","room-id","room-ids","date","time","start-date","end-date","time-min","time-max"],"note":"--with carries names resolved by the shortcut; identifier spellings and room booking inputs cannot be supplied by name-only normalization. Only explicit event-time spellings are mapped; range-bound or date-only spellings remain blocked.","scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end"},"scope_strict":true},
|
||||
"calendar +book-search": {"scoped_aliases":{"search":"query","name":"query"},"scope_strict":true,"note":"On this exact calendar-book search, --search, --name, and --query carry the same calendar-name keyword."},
|
||||
"calendar +cancel-event": {"scoped_aliases":{"id":"event"},"block":["calendar-id","calendar-book-id","acl-id","room-id"],"scope_strict":true,"note":"The only identifier role is the eventId to cancel; other calendar resource IDs are rejected."},
|
||||
"calendar +conflicts": {"scoped_aliases":{"day-offset":"in-days","days-from-today":"in-days"},"block":["days","duration","start","end","from","to"],"scope_strict":true,"note":"--in-days is an integer offset from today. Exact day-offset spellings preserve the value; generic duration and absolute/range time names are different roles."},
|
||||
"calendar +free": {"block":["user","user-id","user-ids","users","with"],"note":"--who carries one person name for runtime resolution, so exact name/person spellings are command-scoped while stable user identifiers stay blocked. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end","name":"who","person":"who","person-name":"who"},"scope_strict":true},
|
||||
"calendar +free-slots": {"scoped_aliases":{"start-hour":"from","end-hour":"to","day-offset":"in-days","days-from-today":"in-days"},"block":["start","end","start-time","end-time","time-min","time-max","days","duration"],"scope_strict":true,"note":"--from/--to are integer hours and --in-days is a relative day offset; exact unit-preserving spellings are command-scoped while ISO timestamp and generic duration names stay blocked."},
|
||||
"calendar +freebusy": {"block":["who","with","name","names","attendee-names","participant-names","room-name","group-id"],"note":"This shortcut accepts stable userId/roomId lists; singular --room-id is one list element and normalizes safely, while names and room-search filters require different commands. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end"},"scope_strict":true},
|
||||
"calendar +invite": {"scoped_aliases":{"id":"event"},"block":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids","calendar-id"],"scope_strict":true,"note":"--with is a name list resolved by the shortcut; stable person identifiers are not renamed into names."},
|
||||
"calendar +reschedule": {"scoped_aliases":{"id":"event","from":"start","begin":"start","start-time":"start","to":"end","end-time":"end"},"block":["calendar-id","calendar-book-id","acl-id","date","time","start-date","end-date","time-min","time-max"],"scope_strict":true,"note":"The only identifier role is the eventId being rescheduled. Only explicit new-event-time spellings are mapped; range-bound or date-only spellings remain blocked."},
|
||||
"calendar +room-groups": {"scoped_aliases":{"page-index":"page"},"block":["cursor","page-token"],"scope_strict":true,"note":"This endpoint uses a zero-based pageIndex; cursor pagination cannot be converted to it."},
|
||||
"calendar +room-search": {"scoped_aliases":{"query":"room-name","name":"room-name"},"block":["room","room-id","room-ids","rooms","group-id","location"],"scope_strict":true,"note":"This shortcut accepts a room display-name keyword and returns roomId; identifier/location inputs are different roles."},
|
||||
"calendar +suggest-time": {"block":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids"],"note":"--with carries names resolved by the shortcut; stable identifier lists belong to event suggest/freebusy. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end"},"scope_strict":true},
|
||||
"calendar acl delete": {"block":["calendar-id","calendar-book-id","event","event-id","room-id","user-id"],"note":"--acl-id identifies one access-control grant, not a calendar, event, room, or user."},
|
||||
"calendar attachment add": {"block":["file","file-id","file-ids","attachments"],"note":"--files requires <fileId>:<name> elements; a bare file identifier cannot be wrapped or named by the alias layer."},
|
||||
"calendar attendee add": {"block":["attendee-name","attendee-names","participant-name","participant-names","with","who","open-dingtalk-ids"],"note":"--attendees carries a userId list; names require a resolver shortcut and openDingTalkIds are a distinct value domain."},
|
||||
"calendar attendee delete": {"block":["attendee-name","attendee-names","participant-name","participant-names","with","who","open-dingtalk-ids"],"note":"--attendees carries a userId list; names require a resolver shortcut and openDingTalkIds are a distinct value domain."},
|
||||
"calendar busy search": {"block":["who","with","name","names","attendee-name","attendee-names","participant-names","room-name"],"note":"--users/--rooms are stable ID lists; singular --room-id safely denotes one list element, the existing hidden --room-ids compatibility stays native, and names require a resolver or room search."},
|
||||
"calendar event create": {"scoped_aliases":{"reminder-minutes":"remind-minutes","reminder-offset-minutes":"remind-minutes"},"block":["attendee-name","attendee-names","participant-name","participant-names","with","who","room-name","group-id","reminder-time","remind-at","at","due","duration"],"scope_strict":true,"note":"Creation accepts stable attendee/room identifiers and minute offsets; singular --room-id safely denotes one list element and the existing hidden --room-ids compatibility stays native. Reminder aliases are exact to this command; names, room search criteria, absolute reminder times, and generic duration need separate resolution or value transforms."},
|
||||
"calendar event respond": {"scoped_aliases":{"response-status":"status","response":"status"},"block":["state","done","free-busy","availability"],"scope_strict":true,"note":"The response/status flag-name aliases are exact to this command. --status still uses this leaf's own attendee-response enum; values are not translated, and busy/free state or boolean completion names stay blocked."},
|
||||
"calendar event suggest": {"block":["who","with","name","names","attendee-name","attendee-names","participant-names","open-dingtalk-ids"],"note":"--users carries stable userIds; name resolution belongs to +suggest-time."},
|
||||
"calendar event update": {"block":["attendees","participants","rooms","room-id","room-ids","room-name","group-id","remind-minutes","reminder-minutes"],"note":"The command's existing hidden --freebusy compatibility flag remains native. Attendee, room, and reminder changes require dedicated commands or are unsupported by update."},
|
||||
"calendar room add": {"block":["room","room-name","group-id","location"],"note":"--rooms is a roomId list returned by room search; singular --room-id safely denotes one list element and the existing hidden --room-ids compatibility stays native, while generic room, name, group, and location roles are not promoted automatically."},
|
||||
"calendar room delete": {"block":["room","room-name","group-id","location"],"note":"--rooms is a roomId list returned by room search; singular --room-id safely denotes one list element and the existing hidden --room-ids compatibility stays native, while generic room, name, group, and location roles are not promoted automatically."},
|
||||
"calendar room search": {"scoped_aliases":{"room-group-id":"group-id","group":"group-id"},"block":["room","room-id","room-ids","rooms","location"],"scope_strict":true,"note":"On this exact command, room-group-id/group denote the meeting-room group filter; search consumes room name/group/time filters and returns roomId, while existing room identifiers and free-text event location are different roles."},
|
||||
"calendar +my-free": {"scoped_aliases":{"to":"end"},"scope_strict":true,"note":"On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end."},
|
||||
"calendar +create": {"scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end","freebusy":"free-busy","user-ids":"attendees","users":"attendees","attendee-ids":"attendees"},"block":["id","event","event-id","who","with","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","room-name","group-id","rich-text-desc","date","time","start-date","end-date","time-min","time-max"],"scope_strict":true,"note":"The shortcut accepts stable attendee userId and roomId lists plus one calendarId; singular --room-id is one list element and normalizes safely. Names, openDingTalkIds, event identifiers, room-search filters, and HTML rich text are different roles; --to and --freebusy are exact value-preserving spellings on this command."},
|
||||
"calendar +get": {"ambiguous":["id"],"note":"The command carries one required eventId and one optional calendarId, so bare --id cannot safely select a role."},
|
||||
"calendar +room-find": {"scoped_aliases":{"to":"end","page-index":"page","room-group-id":"group-id","group":"group-id","query":"room-name","name":"room-name"},"block":["cursor","page-token","next-cursor","room","room-id","room-ids","rooms","location"],"scope_strict":true,"note":"This shortcut combines ISO time bounds, a zero-based pageIndex, and room-name/group filters. Cursor pagination and existing room identifiers cannot be converted to these filter roles."},
|
||||
"calendar +rsvp": {"scoped_aliases":{"response-status":"status","response":"status"},"ambiguous":["id"],"block":["state","done","free-busy","availability"],"scope_strict":true,"note":"The command carries eventId and calendarId together; bare --id is ambiguous. Response/status aliases are exact to this leaf, but --status still uses +rsvp's own enum values; values are never translated, and busy/free state names stay blocked."},
|
||||
"calendar +search-event": {"scoped_aliases":{"to":"end","search":"query"},"block":["id","event","event-id","page","page-index","offset"],"scope_strict":true,"note":"The query searches title, description, and location within one calendar page. This route uses cursor pagination; event identifiers and page-index/offset models are different roles."},
|
||||
"calendar +suggestion": {"scoped_aliases":{"to":"end"},"block":["who","with","name","names","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","room-name","room-id","room-ids","rooms"],"scope_strict":true,"note":"This shortcut accepts stable userId values. Person names belong to +suggest-time, openDingTalkIds are a different value domain, and room availability is a different query."},
|
||||
"calendar +update": {"scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end","freebusy":"free-busy","add-users":"add-attendees","add-user-ids":"add-attendees","remove-users":"remove-attendees","remove-user-ids":"remove-attendees"},"ambiguous":["id","attendees","users","user-ids"],"block":["who","with","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","rooms","room-id","room-ids","room-name","group-id","rich-text-desc","remind-minutes","reminder-minutes","date","time","start-date","end-date","time-min","time-max"],"scope_strict":true,"note":"Bare --id cannot choose eventId versus calendarId, and a generic attendee list cannot choose add versus remove. Directional userId aliases are safe; names, openDingTalkIds, room changes, rich text, and reminders require different commands or capabilities."},
|
||||
"chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."},
|
||||
"chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."},
|
||||
"chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."},
|
||||
@@ -114,7 +165,6 @@
|
||||
"chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."},
|
||||
"chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."},
|
||||
"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": "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},
|
||||
@@ -265,7 +315,115 @@
|
||||
"aitable +import-upload": {"scoped_aliases":{"size":"file-size"},"scope_strict":true,"note":"On this import-upload command --file-size is the local file size in bytes; the mapping is command-local because size is a pagination synonym elsewhere."},
|
||||
"aitable +table-copy": {"scoped_aliases":{"table-id":"source-table-id"},"ambiguous":["base","base-id","base-token"],"scope_strict":true,"note":"The only existing table role is the source table, while an unqualified Base spelling cannot choose between source-base-id and target-base-id."},
|
||||
"aitable workflow run": {"block":["record-id"],"note":"The command accepts a list of one to five record IDs. A singular --record-id is deliberately blocked instead of relying on edit-distance correction; --table-id and --record-ids must remain paired for record-triggered workflows."},
|
||||
"aitable workflow history": {"scoped_aliases":{"start-time":"after-time","end-time":"before-time","page-index":"page"},"block":["cursor","offset","page-token","next-cursor","next-token","page-no","page-number","current-page"],"scope_strict":true,"note":"This endpoint uses a zero-based page plus per-page size, not cursor/offset pagination. Time-bound aliases preserve the required Unix-millisecond value unchanged."}
|
||||
"aitable workflow history": {"scoped_aliases":{"start-time":"after-time","end-time":"before-time","page-index":"page"},"block":["cursor","offset","page-token","next-cursor","next-token","page-no","page-number","current-page"],"scope_strict":true,"note":"This endpoint uses a zero-based page plus per-page size, not cursor/offset pagination. Time-bound aliases preserve the required Unix-millisecond value unchanged."},
|
||||
"minutes +share": {"bind":{"member-uids":"user_ids","id":"minutes_task_uuid"},"block":["user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id","policy"],"note":"--member-uids is a DingTalk userId list; singular inputs and staffId/unionId/openDingTalkId value domains are not promoted. Semantic permission names and numeric policy values require value conversion and are intentionally blocked across surfaces. The broad real --id is explicitly bound to the Minutes taskUuid concept."},
|
||||
"minutes +unshare": {"bind":{"member-uids":"user_ids","id":"minutes_task_uuid"},"block":["user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id"],"note":"--member-uids is a DingTalk userId list; singular inputs and staffId/unionId/openDingTalkId value domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept."},
|
||||
"minutes permission add": {"bind":{"member-uids":"user_ids","ids":"minutes_task_uuids"},"block":["id","task-uuid","uuid","user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id","permission"],"note":"--ids is a taskUuid list and --member-uids is a DingTalk userId list. Singular task IDs, singular member IDs, staffId/unionId/openDingTalkId domains, and semantic permission names are not promoted into these list/numeric roles."},
|
||||
"minutes permission remove": {"bind":{"member-uids":"user_ids","ids":"minutes_task_uuids"},"block":["id","task-uuid","uuid","user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id"],"note":"--ids is a taskUuid list and --member-uids is a DingTalk userId list. Singular task/member inputs and staffId/unionId/openDingTalkId domains are not promoted."},
|
||||
"minutes speaker replace": {"scoped_aliases":{"user-id":"target-uid","userid":"target-uid","uid":"target-uid","old-speaker":"from","source-speaker":"from","new-speaker":"to","target-speaker":"to"},"block":["target-uids","user","staff-id","union-id","open-dingtalk-id"],"scope_strict":true,"note":"--id is taskUuid, --from/--to are speaker-label roles, and singular --target-uid requires one stable DingTalk userId. Plural target IDs and other user identity domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +speaker-replace": {"scoped_aliases":{"user-id":"target-uid","userid":"target-uid","uid":"target-uid","old-speaker":"from","source-speaker":"from","new-speaker":"to","target-speaker":"to"},"block":["target-uids","user","staff-id","union-id","open-dingtalk-id"],"scope_strict":true,"note":"--id is taskUuid, --from/--to are speaker-label roles, and singular --target-uid requires one stable DingTalk userId. Plural target IDs and other user identity domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes audio-memo list": {"scoped_aliases":{"limit":"max","page-size":"max","max-results":"max","next-cursor":"cursor"},"block":["next-token","page-token","next-page-token"],"scope_strict":true,"note":"This command uses --max for the count bound and an integer cursor; opaque pagination tokens cannot be renamed into that integer domain."},
|
||||
"minutes tag query": {"scoped_aliases":{"id":"tag-id"},"block":["tag-ids","group-id","group-name"],"scope_strict":true,"note":"The generic identifier on this exact command is one Minutes tag ID, not a taskUuid, tag list, or group identifier."},
|
||||
"minutes replace-text": {"scoped_aliases":{"find":"search","original-text":"search","new-text":"replace","replacement-text":"replace"},"block":["text","content","body"],"scope_strict":true,"note":"Search and replacement are two distinct required roles; role-free text spellings cannot safely choose one side. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +replace-batch": {"scoped_aliases":{"pairs":"pair"},"block":["search","replace","original-text","replacement-text"],"scope_strict":true,"note":"The repeatable --pair value is encoded as source=>replacement; name normalization cannot compose it from two separate values. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +download": {"ambiguous":["destination","path","save-path"],"note":"The destination role depends on singular --output versus batch --output-dir and cannot be selected from a generic path name. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes list all": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."},
|
||||
"minutes list mine": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."},
|
||||
"minutes list shared": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."},
|
||||
"minutes +search": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."},
|
||||
"minutes +apply-permission": {"block":["policy"],"note":"Semantic permission names and numeric policy values require value conversion and cannot be passed through by renaming. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes permission apply": {"block":["ids","task-uuids","uuids","permission"],"note":"This leaf accepts one taskUuid and a numeric policy. Task lists and semantic permission names require cardinality/value conversion and cannot be passed through by renaming."},
|
||||
"minutes get info": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes get summary": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes get keywords": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes get transcription": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes get todos": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes get audio": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."},
|
||||
"minutes mind-graph create": {"block":["ids","task-uuids","uuids"],"note":"This leaf creates a mind graph for exactly one taskUuid; a task list cannot be renamed into the singular role."},
|
||||
"minutes mind-graph status": {"block":["ids","task-uuids","uuids"],"note":"This leaf queries the mind graph for exactly one taskUuid; a task list cannot be renamed into the singular role."},
|
||||
"minutes upload create": {"block":["file","file-path","path","template-ids"],"note":"The atomic create command requires file-name, byte-sized file-size, and at most one template ID. A local path needs stat/upload orchestration, and a template list cannot be renamed into the singular role."},
|
||||
"minutes upload complete": {"block":["session-ids","upload-session-ids","upload-ids"],"note":"The command completes exactly one upload session; plural session spellings cannot be renamed into its singular sessionId."},
|
||||
"minutes upload cancel": {"block":["session-ids","upload-session-ids","upload-ids"],"note":"The command cancels exactly one upload session; plural session spellings cannot be renamed into its singular sessionId."},
|
||||
"minutes +upload": {"block":["file-name","file-size","size-bytes","template-ids"],"note":"The Shortcut requires a local --file path, computes file metadata, and accepts at most one template ID; atomic metadata or template lists cannot be renamed into those roles."},
|
||||
"minutes record start": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. Plural or upload-session spellings are blocked."},
|
||||
"minutes record pause": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes record resume": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes record stop": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +record-start": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. Plural or upload-session spellings are blocked."},
|
||||
"minutes +record-pause": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +record-resume": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +record-stop": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +speaker-insights": {"block":["task-ids"],"note":"--id is one Minutes taskUuid while optional --task-id is one asynchronous recovery handle; neither role accepts a list. The broad real --id is explicitly bound to the single Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}},
|
||||
"minutes +upload-and-analyze": {"scoped_aliases":{"task-uuid":"resume-id","uuid":"resume-id"},"block":["file-name","file-size","size-bytes","id","resume-ids","template-ids"],"scope_strict":true,"note":"--resume-id is one already-created taskUuid and --template-id is singular. Explicit taskUuid spellings may resume one task, while generic --id, list spellings, and atomic file metadata remain blocked against route/cardinality confusion."},
|
||||
"minutes +action-items": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +detail": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +export-pack": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +mindmap": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +record-wrap-up": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +summary": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +transcript": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes +update": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes update summary": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"minutes update title": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."},
|
||||
"todo task create-sub": {"bind":{"parent-id":"todo_task_id"},"scoped_aliases":{"parent-task-id":"parent-id"},"ambiguous":["task"],"scope_strict":true,"note":"This command has a parent taskId and a new child title. Reviewed task-id/todo-id spellings reduce to --parent-id, while role-free --task cannot choose between the parent target and child title and must stop before execution."},
|
||||
"todo task update": {"ambiguous":["task"],"note":"This command has both --task-id and --title. Role-free --task cannot safely choose the existing target identifier or replacement title."},
|
||||
"todo +update": {"ambiguous":["task"],"note":"This shortcut has both --task-id and --title. Role-free --task cannot safely choose the existing target identifier or replacement title."},
|
||||
"todo +todo-done": {"scoped_aliases":{"query":"task","keyword":"task","title":"task"},"block":["task-id","todo-id"],"scope_strict":true,"note":"The real --task is a title substring query, not taskId. Query/title spellings preserve the search text; stable IDs must use todo task done."},
|
||||
"todo task create": {"scoped_aliases":{"task":"title"},"note":"This creation command has one task-title role, so the exact --task spelling can preserve the title text without competing with an existing target taskId."},
|
||||
"todo +create": {"scoped_aliases":{"task":"title"},"note":"This creation shortcut has one task-title role, so the exact --task spelling can preserve the title text without competing with an existing target taskId."},
|
||||
"todo +assign": {"scoped_aliases":{"assignee-name":"to","title":"task"},"block":["executor-id","executor-ids","executors","user-id","user-ids"],"note":"This shortcut resolves one human name from --to and creates one title from --task. Exact name/title spellings preserve those roles; stable userId spellings must not be renamed into a name-resolution input."},
|
||||
"todo +assign-multi": {"scoped_aliases":{"assignee-names":"to","title":"task"},"block":["executor-id","executor-ids","executors","user-id","user-ids"],"note":"This shortcut resolves a comma-separated list of human names from --to and creates one title from --task. Exact name/title spellings preserve those roles; stable userId spellings must not be renamed into a name-resolution input."},
|
||||
"todo +remind": {"scoped_aliases":{"deadline":"at","due":"at","due-time":"at","title":"task"},"block":["remind-at","reminder-at","reminder-rules","reminder-time","reminder-time-stamp"],"scope_strict":true,"note":"This shortcut has one title role and its real --at writes the task deadline dueTime. Exact title/deadline spellings preserve those values; independent reminder spellings are rejected and must route to todo +reminder or todo task add-reminder/reset-reminder."},
|
||||
"todo +search": {"scoped_aliases":{"subject":"query","title":"query","task":"query"},"block":["limit","max-result","max-results","page","page-size","per-page","size","take","top"],"note":"This shortcut searches the task card subject/title substring across complete traversal. Subject/title/task spellings preserve query text; result-count and page-size names are not aliases for the real --max-pages safety cap."},
|
||||
"todo +complete": {"block":["done","is-done","status","todo-status"],"note":"The target state is fixed by the +complete command. A status flag would falsely imply caller-selectable state; use +reopen or todo task done for the opposite state."},
|
||||
"todo +reopen": {"block":["done","is-done","status","todo-status"],"note":"The target state is fixed by the +reopen command. A status flag would falsely imply caller-selectable state; use +complete or todo task done for the opposite state."},
|
||||
"todo +reminder": {"scoped_aliases":{"remind-at":"at","reminder-at":"at","reminder-time":"at","reminder-time-stamp":"at"},"block":["deadline","due","due-time","reminder-rules"],"note":"The real --at is the ISO-8601 customTime reminder timestamp. Reminder-time spellings preserve that value; task deadlines and a JSON reminder-rules array are different roles or structures."},
|
||||
"todo task add-reminder": {"scoped_aliases":{"at":"reminder-time-stamp","reminder-at":"reminder-time-stamp","reminder-time":"reminder-time-stamp"},"block":["deadline","due","due-time","reminder-rules"],"note":"This command writes one independent reminder rule. --at is the same ISO-8601 customTime value as --reminder-time-stamp; a task deadline or JSON rules array cannot be reduced to one atomic reminder field. The inherited real --remind-at remains command-owned and cannot be intercepted by PreParse."},
|
||||
"todo task reset-reminder": {"block":["at","base-time","deadline","due","due-date-offset","due-time","reminder-time-stamp"],"note":"This command accepts one --reminder-rules JSON array. Atomic reminder fields or task deadlines require value construction and are not name-only aliases. The inherited real --remind-at cannot be blocked by PreParse."},
|
||||
"todo task list": {"scoped_aliases":{"limit":"size","max-results":"size","page-number":"page","priorities":"priority"},"block":["plan-finish-end","plan-finish-start","priority-id","priority-name"],"scope_strict":true,"note":"Here --size is the total returned-item target and auto-pages beyond 20, so only total-limit spellings are mapped. --page-number preserves the one-based page value. The list-only --priorities spelling preserves the unchanged 10/20/30/40 filter values without claiming a global priority concept. The sibling shortcut's plan-finish-start/end use Unix milliseconds and are blocked because this command requires ISO-8601 date strings."},
|
||||
"todo +get-my-tasks": {"scoped_aliases":{"page-number":"page","page-size":"size","per-page":"size","priorities":"priority"},"block":["limit","max-result","max-results","plan-finish-date-end","plan-finish-date-start","priority-id","priority-name","take","top"],"note":"This shortcut's --size is per-page, and --all ignores pageSize; only explicit per-page spellings are aliases. Total-limit spellings are blocked. The list-only --priorities spelling preserves the unchanged 10/20/30/40 filter values without claiming a global priority concept. The command requires Unix millisecond plan-finish-start/end values, so the stable command's date-named ISO-8601 flags cannot be converted by name-only normalization."},
|
||||
"todo +list-comment": {"scoped_aliases":{"page-number":"page"},"note":"The explicit page-number spelling preserves the one-based page value; page-size remains handled by the reviewed pagination-size concept."},
|
||||
"todo comment list": {"scoped_aliases":{"page-number":"page"},"note":"The explicit page-number spelling preserves the one-based page value; page-size remains handled by the reviewed pagination-size concept."},
|
||||
"todo +get-related-tasks": {"scoped_aliases":{"todo-status":"status"},"block":["done","is-done"],"note":"This shortcut accepts the service enum status value such as TODO, not a boolean completion flag. The product-qualified spelling is safe; boolean spellings are rejected."},
|
||||
"todo tag add": {"block":["tag-ids","tag-names","tags"],"note":"The command requires existing tag code values. Generic tags may be names or IDs and are not safely equivalent to --tag-codes."},
|
||||
"todo tag delete": {"block":["tag-ids","tag-names","tags"],"note":"The command deletes tag definitions by existing tag code. Generic tags may be names or IDs and are not safely equivalent to --tag-codes."},
|
||||
"todo tag update": {"block":["tag-codes","tag-ids","tag-names","tags"],"note":"The real --user-tags is a JSON array of tag objects. A flat list of codes, names, or IDs cannot be renamed into that structure."},
|
||||
"wiki +resolve-space": {"bind":{"name":"search_query"},"scoped_aliases":{"knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"--name is a knowledge-space name search string, not workspaceId. Search spellings preserve the unchanged text; ID spellings stop before lookup."},
|
||||
"wiki +wiki-new-doc": {"scoped_aliases":{"doc-title":"title","document-title":"title","knowledge-base-name":"space","space-name":"space","wiki-name":"space","workspace-name":"space"},"ambiguous":["name"],"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"--space is a knowledge-space name search string and --title is the new document title. Role-free --name cannot choose between them, and workspace/storage IDs are not accepted by this name-resolving shortcut."},
|
||||
"wiki +node-get": {"scoped_aliases":{"doc-id":"node","document-id":"node","document-url":"node","id":"node","node-id":"node","url":"node","wiki-node-id":"node"},"block":["dentry-id","folder","folder-id","knowledge-base-id","space-id","workspace","workspace-id"],"scope_strict":true,"note":"This exact shortcut has one Wiki node target and explicitly accepts nodeId or URL. Numeric dentryId, folder and workspace identifiers are different roles."},
|
||||
"wiki +node-copy": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Source node, destination workspace and destination folder are separate ID roles. --node-id and explicit source/target spellings preserve values; role-free target IDs stop before copy."},
|
||||
"wiki +move": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Source node, destination workspace and destination folder are separate ID roles. --node-id and explicit source/target spellings preserve values; role-free target IDs stop before move."},
|
||||
"wiki +move-to-drive": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"The source is one Wiki node while --folder is a Drive destination folder. --node-id preserves the source value; workspace and storage-space identifiers are not substitutes for either role."},
|
||||
"wiki +node-delete": {"scoped_aliases":{"document-id":"node","node-id":"node","target-node-id":"node","wiki-node-id":"node"},"ambiguous":["id"],"block":["dentry-id","folder-id","space-id"],"scope_strict":true,"note":"The destructive target node and the workspace confirmation scope are distinct required IDs. --node-id is explicit and value-preserving; generic --id cannot choose one and must stop before confirmation."},
|
||||
"wiki node copy": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Native hidden node/folder/workspace compatibility flags remain native. These additional role-qualified spellings preserve the same values; generic target IDs remain ambiguous."},
|
||||
"wiki node move": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Native hidden node/folder/workspace compatibility flags remain native. These additional role-qualified spellings preserve the same values; generic target IDs remain ambiguous."},
|
||||
"wiki node delete": {"scoped_aliases":{"document-id":"node","target-node-id":"node","wiki-node-id":"node"},"ambiguous":["id"],"block":["dentry-id","folder-id","space-id"],"scope_strict":true,"note":"The node target and workspace authorization scope are distinct IDs. Native hidden node/workspace aliases remain native; generic --id must stop before deletion."},
|
||||
"wiki +node-list": {"scoped_aliases":{"directory-id":"folder","folder-id":"folder","next-cursor":"cursor","next-page-token":"cursor","next-token":"cursor","page-size":"limit","parent-folder-id":"folder","parent-node-id":"folder","per-page":"limit"},"ambiguous":["id","max-result","max-results","size","take","top"],"block":["dentry-id","node-id","offset","page","space-id"],"scope_strict":true,"note":"Workspace and optional parent folder are separate IDs. Per-page --limit and aggregate --max-items are separate limits; generic maximum spellings cannot choose one."},
|
||||
"wiki +node-create": {"scoped_aliases":{"directory-id":"folder","display-name":"name","folder-id":"folder","node-name":"name","parent-folder-id":"folder","parent-node-id":"folder","node-type":"type"},"ambiguous":["id"],"block":["dentry-id","node-id","query","space-id","space-type","wiki-space-type"],"scope_strict":true,"note":"Workspace, parent folder, node display name and node type are distinct roles. Node-type aliases stay local; search and space-type spellings are rejected."},
|
||||
"wiki node list": {"scoped_aliases":{"directory-id":"folder","folder-id":"folder"},"ambiguous":["id"],"block":["dentry-id","max-items","node-id","offset","page","page-all","page-delay","page-limit","space-id"],"scope_strict":true,"note":"Native hidden parent/pagination/workspace aliases remain native. This leaf is single-page; Shortcut auto-page controls must not be accepted on the native command."},
|
||||
"wiki node create": {"scoped_aliases":{"directory-id":"folder","display-name":"name","folder-id":"folder","node-name":"name","node-type":"type"},"ambiguous":["id"],"block":["dentry-id","node-id","query","space-id","space-type","wiki-space-type"],"scope_strict":true,"note":"Native hidden parent/workspace aliases remain native. Workspace, parent folder, display name and node type remain distinct roles."},
|
||||
"wiki +node-search": {"scoped_aliases":{"node-name":"query"},"block":["id","name","node-id","offset","page","space-id"],"scope_strict":true,"note":"The command searches within one workspace by unchanged keyword text. A node ID or role-free name is not a workspace-scoped search parameter."},
|
||||
"wiki node search": {"scoped_aliases":{"node-name":"query"},"block":["id","name","node-id","offset","page","space-id"],"scope_strict":true,"note":"Native hidden query/pagination/workspace aliases remain native. Search text, workspace and node identifiers are distinct roles."},
|
||||
"wiki +space-create": {"scoped_aliases":{"display-name":"name","knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["node-name","query","title","workspace","workspace-id"],"scope_strict":true,"note":"This creates a new knowledge-base container. Name and plain description aliases preserve text; existing workspace IDs, search queries and node titles are different roles."},
|
||||
"wiki space create": {"scoped_aliases":{"display-name":"name","knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["node-name","query","title","workspace","workspace-id"],"scope_strict":true,"note":"Native hidden --description remains native. This command creates a container; existing workspace IDs, search queries and node titles are not creation names."},
|
||||
"wiki +space-list": {"scoped_aliases":{"next-cursor":"cursor","next-page-token":"cursor","next-token":"cursor","page-size":"limit","per-page":"limit","space-type":"type","wiki-space-type":"type"},"ambiguous":["max-result","max-results","size","take","top"],"block":["id","knowledge-base-id","node-type","offset","page","space","space-id","storage-space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This Shortcut lists only orgWikiSpace/myWikiSpace and has separate per-page and aggregate limits. Drive storage types belong to native wiki space list."},
|
||||
"wiki space list": {"scoped_aliases":{"space-type":"type"},"block":["id","knowledge-base-id","max-items","node-type","offset","page","page-all","page-delay","page-limit","space","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This composite native leaf can list Wiki or Drive storage spaces by --type but is single-page. A target workspace ID and Shortcut auto-page controls are not accepted."},
|
||||
"wiki +space-search": {"scoped_aliases":{"knowledge-base-name":"query","space-name":"query","wiki-name":"query","workspace-name":"query"},"block":["id","knowledge-base-id","space-id","space-type","type","wiki-space-type","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This Shortcut searches organization knowledge bases by keyword and does not expose native --type myWikiSpace routing. IDs and type selectors must not be rewritten into query text."},
|
||||
"wiki space search": {"scoped_aliases":{"knowledge-base-name":"query","space-name":"query","space-type":"type","wiki-name":"query","wiki-space-type":"type","workspace-name":"query"},"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"Native hidden query/pagination aliases remain native. Name spellings are unchanged search text, while --type myWikiSpace is a distinct route selector."},
|
||||
"wiki +space-get": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"This exact command has one knowledge-base workspace target, so role-free --id is safe locally. Drive storage-space, node and folder IDs remain separate domains."},
|
||||
"wiki space get": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"Native --workspace-id remains a hidden compatibility flag. This exact leaf has one knowledge-base workspace target; other ID domains are blocked."},
|
||||
"wiki +delete-space": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"The destructive command has one confirmed knowledge-base workspace target. Drive storage-space, node and folder IDs must stop before confirmation."},
|
||||
"wiki space delete": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"Native --workspace-id remains a hidden compatibility flag. The destructive target is one knowledge-base workspace, not a Drive space or child node."},
|
||||
"wiki +feed-list": {"scoped_aliases":{"page-size":"limit","per-page":"limit"},"ambiguous":["max-result","max-results","size","take","top"],"block":["id","node-id","offset","page","space-id"],"scope_strict":true,"note":"Per-page --limit and aggregate --max-items are separate bounds. Feed scope is one workspace; node and storage-space IDs are different roles."},
|
||||
"wiki feed list": {"scoped_aliases":{"id":"workspace"},"block":["max-items","node-id","offset","page","page-all","page-delay","page-limit","space-id"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. This leaf is single-page, and Shortcut auto-page controls are not accepted."},
|
||||
"wiki +member-add": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."},
|
||||
"wiki member add": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."},
|
||||
"wiki +member-update": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."},
|
||||
"wiki member update": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."},
|
||||
"wiki +member-remove": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."},
|
||||
"wiki member remove": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."},
|
||||
"wiki +member-list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"This command lists members and can filter by a role list. It does not accept user filters or cursor auto-pagination, and workspace/user IDs must not be conflated."},
|
||||
"wiki member list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. The endpoint has no user filter or cursor and returns at most 50 members."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
@@ -300,7 +458,6 @@
|
||||
{"command": "drive list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id+morph", "occ": 2},
|
||||
{"command": "mail thread list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
|
||||
{"command": "mail user search", "emitted": "query", "expect": "keyword", "via": "concept:search_query", "occ": 2},
|
||||
{"command": "oa +list-executed", "emitted": "take", "expect": "limit", "via": "concept:pagination_size", "occ": 2},
|
||||
{"command": "oa approval search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2},
|
||||
{"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2},
|
||||
{"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1},
|
||||
@@ -397,7 +554,6 @@
|
||||
{"command": "chat message send", "emitted": "file-path", "expect": "file", "via": "native:reviewed-compatibility-alias"},
|
||||
{"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"},
|
||||
{"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"},
|
||||
{"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"},
|
||||
@@ -695,7 +851,258 @@
|
||||
{"command":"aitable workflow history","emitted":"page-index","expect":"page","via":"override:scoped-zero-based-page"},
|
||||
{"command":"aitable workflow history","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:page-vs-cursor-model"},
|
||||
{"command":"aitable workflow history","emitted":"offset","expect":"did-you-mean:blocked","via":"guard:page-vs-offset-model"},
|
||||
{"command":"aitable workflow history","emitted":"page-number","expect":"did-you-mean:blocked","via":"guard:zero-vs-one-based-page"}
|
||||
{"command":"aitable workflow history","emitted":"page-number","expect":"did-you-mean:blocked","via":"guard:zero-vs-one-based-page"},
|
||||
{"command":"calendar +agenda","emitted":"from","expect":"start","via":"concept:time_start"},
|
||||
{"command":"calendar +agenda","emitted":"to","expect":"end","via":"override:scoped_strict"},
|
||||
{"command":"calendar +agenda","emitted":"max-results","expect":"limit","via":"concept:pagination_size"},
|
||||
{"command":"calendar +agenda","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"},
|
||||
{"command":"calendar +agenda","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"},
|
||||
{"command":"calendar +agenda","emitted":"id","expect":"did-you-mean:blocked","via":"guard:calendar-id-vs-event-id"},
|
||||
{"command":"calendar +attendee-list","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"},
|
||||
{"command":"calendar +attendee-list","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"},
|
||||
{"command":"calendar +attendee-list","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"},
|
||||
{"command":"calendar +book","emitted":"summary","expect":"title","via":"concept:calendar_event_title"},
|
||||
{"command":"calendar +book","emitted":"attendee-names","expect":"with","via":"concept:calendar_person_name_list"},
|
||||
{"command":"calendar +book","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"},
|
||||
{"command":"calendar +book-search","emitted":"keyword","expect":"query","via":"concept:search_query"},
|
||||
{"command":"calendar +book-search","emitted":"search","expect":"query","via":"override:scoped_strict"},
|
||||
{"command":"calendar +book-search","emitted":"name","expect":"query","via":"override:scoped_strict"},
|
||||
{"command":"calendar +cancel-event","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"},
|
||||
{"command":"calendar +cancel-event","emitted":"id","expect":"event","via":"override:scoped_strict"},
|
||||
{"command":"calendar +cancel-event","emitted":"calendar-id","expect":"did-you-mean:blocked","via":"guard:event-id-vs-calendar-id"},
|
||||
{"command":"calendar +free","emitted":"name","expect":"who","via":"override:scoped_strict"},
|
||||
{"command":"calendar +free","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"},
|
||||
{"command":"calendar +free-slots","emitted":"start-hour","expect":"from","via":"override:scoped_strict"},
|
||||
{"command":"calendar +free-slots","emitted":"end-hour","expect":"to","via":"override:scoped_strict"},
|
||||
{"command":"calendar +free-slots","emitted":"day-offset","expect":"in-days","via":"override:scoped_strict"},
|
||||
{"command":"calendar +free-slots","emitted":"start","expect":"did-you-mean:blocked","via":"guard:hour-vs-iso-time"},
|
||||
{"command":"calendar +freebusy","emitted":"user-ids","expect":"users","via":"concept:user_ids"},
|
||||
{"command":"calendar +freebusy","emitted":"room-ids","expect":"rooms","via":"concept:calendar_room_ids"},
|
||||
{"command":"calendar +freebusy","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"},
|
||||
{"command":"calendar +freebusy","emitted":"with","expect":"did-you-mean:blocked","via":"guard:id-list-vs-name-list"},
|
||||
{"command":"calendar +my-free","emitted":"from","expect":"start","via":"concept:time_start"},
|
||||
{"command":"calendar +my-free","emitted":"to","expect":"end","via":"override:scoped_strict"},
|
||||
{"command":"calendar +invite","emitted":"id","expect":"event","via":"override:scoped_strict"},
|
||||
{"command":"calendar +invite","emitted":"participant-names","expect":"with","via":"concept:calendar_person_name_list"},
|
||||
{"command":"calendar +invite","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"},
|
||||
{"command":"calendar +reschedule","emitted":"id","expect":"event","via":"override:scoped_strict"},
|
||||
{"command":"calendar +reschedule","emitted":"from","expect":"start","via":"override:scoped_strict"},
|
||||
{"command":"calendar +reschedule","emitted":"to","expect":"end","via":"override:scoped_strict"},
|
||||
{"command":"calendar +room-groups","emitted":"page-size","expect":"limit","via":"concept:pagination_size"},
|
||||
{"command":"calendar +room-groups","emitted":"page-index","expect":"page","via":"override:scoped_strict"},
|
||||
{"command":"calendar +room-groups","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:cursor-vs-page-index"},
|
||||
{"command":"calendar +room-search","emitted":"query","expect":"room-name","via":"override:scoped_strict"},
|
||||
{"command":"calendar +room-search","emitted":"room-id","expect":"did-you-mean:blocked","via":"guard:room-name-vs-room-id"},
|
||||
{"command":"calendar +suggest-time","emitted":"duration-minutes","expect":"duration","via":"concept:calendar_duration_minutes"},
|
||||
{"command":"calendar +suggest-time","emitted":"attendee-names","expect":"with","via":"concept:calendar_person_name_list"},
|
||||
{"command":"calendar +suggest-time","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"},
|
||||
{"command":"calendar +conflicts","emitted":"day-offset","expect":"in-days","via":"override:scoped_strict"},
|
||||
{"command":"calendar attachment add","emitted":"file-id","expect":"did-you-mean:blocked","via":"guard:structured-file-list"},
|
||||
{"command":"calendar attendee add","emitted":"attendee-name","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"},
|
||||
{"command":"calendar busy search","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"},
|
||||
{"command":"calendar busy search","emitted":"room-name","expect":"did-you-mean:blocked","via":"guard:room-name-vs-room-id"},
|
||||
{"command":"calendar event create","emitted":"reminder-minutes","expect":"remind-minutes","via":"override:scoped_strict"},
|
||||
{"command":"calendar event create","emitted":"tz","expect":"timezone","via":"concept:calendar_timezone"},
|
||||
{"command":"calendar event create","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"},
|
||||
{"command":"calendar event create","emitted":"room-name","expect":"did-you-mean:blocked","via":"guard:room-name-vs-room-id"},
|
||||
{"command":"calendar event respond","emitted":"response-status","expect":"status","via":"override:scoped_strict"},
|
||||
{"command":"calendar event respond","emitted":"free-busy","expect":"did-you-mean:blocked","via":"guard:response-vs-busy-state"},
|
||||
{"command":"calendar event suggest","emitted":"duration-minutes","expect":"duration","via":"concept:calendar_duration_minutes"},
|
||||
{"command":"calendar event suggest","emitted":"attendee-names","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"},
|
||||
{"command":"calendar event update","emitted":"tz","expect":"timezone","via":"concept:calendar_timezone"},
|
||||
{"command":"calendar event update","emitted":"rooms","expect":"did-you-mean:blocked","via":"guard:dedicated-room-command"},
|
||||
{"command":"calendar room add","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"},
|
||||
{"command":"calendar room delete","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"},
|
||||
{"command":"calendar room search","emitted":"room-group-id","expect":"group-id","via":"override:scoped_strict"},
|
||||
{"command":"calendar room search","emitted":"room-id","expect":"did-you-mean:blocked","via":"guard:room-filter-vs-room-id"},
|
||||
{"command":"calendar acl delete","emitted":"calendar-id","expect":"did-you-mean:blocked","via":"guard:acl-id-vs-calendar-id"},
|
||||
{"command":"calendar +create","emitted":"summary","expect":"title","via":"concept:calendar_event_title"},
|
||||
{"command":"calendar +create","emitted":"description","expect":"desc","via":"concept:plain_description"},
|
||||
{"command":"calendar +create","emitted":"user-ids","expect":"attendees","via":"override:scoped_strict"},
|
||||
{"command":"calendar +create","emitted":"room-ids","expect":"rooms","via":"concept:calendar_room_ids"},
|
||||
{"command":"calendar +create","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"},
|
||||
{"command":"calendar +create","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"},
|
||||
{"command":"calendar +create","emitted":"to","expect":"end","via":"override:scoped_strict"},
|
||||
{"command":"calendar +create","emitted":"from","expect":"start","via":"override:scoped_strict"},
|
||||
{"command":"calendar +create","emitted":"attendee-names","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"},
|
||||
{"command":"calendar +get","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"},
|
||||
{"command":"calendar +get","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"},
|
||||
{"command":"calendar +get","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"},
|
||||
{"command":"calendar +room-find","emitted":"from","expect":"start","via":"concept:time_start"},
|
||||
{"command":"calendar +room-find","emitted":"to","expect":"end","via":"override:scoped_strict"},
|
||||
{"command":"calendar +room-find","emitted":"page-size","expect":"limit","via":"concept:pagination_size"},
|
||||
{"command":"calendar +room-find","emitted":"page-index","expect":"page","via":"override:scoped_strict"},
|
||||
{"command":"calendar +room-find","emitted":"room-group-id","expect":"group-id","via":"override:scoped_strict"},
|
||||
{"command":"calendar +room-find","emitted":"query","expect":"room-name","via":"override:scoped_strict"},
|
||||
{"command":"calendar +room-find","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:page-index-vs-cursor"},
|
||||
{"command":"calendar +rsvp","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"},
|
||||
{"command":"calendar +rsvp","emitted":"response-status","expect":"status","via":"override:scoped_strict"},
|
||||
{"command":"calendar +rsvp","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"},
|
||||
{"command":"calendar +rsvp","emitted":"free-busy","expect":"did-you-mean:blocked","via":"guard:response-vs-busy-state"},
|
||||
{"command":"calendar +search-event","emitted":"keyword","expect":"query","via":"concept:search_query"},
|
||||
{"command":"calendar +search-event","emitted":"from","expect":"start","via":"concept:time_start"},
|
||||
{"command":"calendar +search-event","emitted":"to","expect":"end","via":"override:scoped_strict"},
|
||||
{"command":"calendar +search-event","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"},
|
||||
{"command":"calendar +search-event","emitted":"max-results","expect":"limit","via":"concept:pagination_size"},
|
||||
{"command":"calendar +search-event","emitted":"page","expect":"did-you-mean:blocked","via":"guard:cursor-vs-page-index"},
|
||||
{"command":"calendar +suggestion","emitted":"user-ids","expect":"users","via":"concept:user_ids"},
|
||||
{"command":"calendar +suggestion","emitted":"duration-minutes","expect":"duration","via":"concept:calendar_duration_minutes"},
|
||||
{"command":"calendar +suggestion","emitted":"from","expect":"start","via":"concept:time_start"},
|
||||
{"command":"calendar +suggestion","emitted":"to","expect":"end","via":"override:scoped_strict"},
|
||||
{"command":"calendar +suggestion","emitted":"tz","expect":"timezone","via":"concept:calendar_timezone"},
|
||||
{"command":"calendar +suggestion","emitted":"attendee-names","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"},
|
||||
{"command":"calendar +update","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"},
|
||||
{"command":"calendar +update","emitted":"from","expect":"start","via":"override:scoped_strict"},
|
||||
{"command":"calendar +update","emitted":"summary","expect":"title","via":"concept:calendar_event_title"},
|
||||
{"command":"calendar +update","emitted":"description","expect":"desc","via":"concept:plain_description"},
|
||||
{"command":"calendar +update","emitted":"add-user-ids","expect":"add-attendees","via":"override:scoped_strict"},
|
||||
{"command":"calendar +update","emitted":"remove-user-ids","expect":"remove-attendees","via":"override:scoped_strict"},
|
||||
{"command":"calendar +update","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"},
|
||||
{"command":"calendar +update","emitted":"attendees","expect":"did-you-mean:ambiguous","via":"guard:add-vs-remove-role"},
|
||||
{"command":"calendar +update","emitted":"room-ids","expect":"did-you-mean:blocked","via":"guard:dedicated-room-command"},
|
||||
{"command":"minutes update title","emitted":"task-uuid","expect":"id","via":"concept:minutes_task_uuid"},
|
||||
{"command":"minutes +record-pause","emitted":"uuid","expect":"id","via":"concept:minutes_task_uuid"},
|
||||
{"command":"minutes +latest","emitted":"query","expect":"keyword","via":"concept:search_query"},
|
||||
{"command":"minutes +transcript","emitted":"query","expect":"keyword","via":"concept:search_query+mutually-exclusive-id"},
|
||||
{"command":"minutes +detail","emitted":"task-uuids","expect":"ids","via":"concept:minutes_task_uuids"},
|
||||
{"command":"minutes get batch","emitted":"uuids","expect":"ids","via":"concept:minutes_task_uuids"},
|
||||
{"command":"minutes +list-all","emitted":"page-size","expect":"limit","via":"concept:pagination_size"},
|
||||
{"command":"minutes +search","emitted":"next-token","expect":"cursor","via":"concept:page_cursor"},
|
||||
{"command":"minutes list mine","emitted":"start-time","expect":"start","via":"concept:time_start"},
|
||||
{"command":"minutes list all","emitted":"end-time","expect":"end","via":"concept:time_end"},
|
||||
{"command":"minutes +summary","emitted":"text","expect":"content","via":"concept:content_text"},
|
||||
{"command":"minutes hot-word add","emitted":"hot-words","expect":"words","via":"concept:minutes_hot_words"},
|
||||
{"command":"minutes upload complete","emitted":"upload-id","expect":"session-id","via":"concept:minutes_upload_session_id"},
|
||||
{"command":"minutes +share","emitted":"user-ids","expect":"member-uids","via":"override:bind-user-id-list"},
|
||||
{"command":"minutes +share","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:member-list-cardinality"},
|
||||
{"command":"minutes +speaker-replace","emitted":"user-id","expect":"target-uid","via":"override:bind-target-user"},
|
||||
{"command":"minutes +speaker-replace","emitted":"staff-id","expect":"did-you-mean:blocked","via":"guard:user-id-value-domain"},
|
||||
{"command":"minutes audio-memo list","emitted":"limit","expect":"max","via":"override:scoped-page-size"},
|
||||
{"command":"minutes audio-memo list","emitted":"next-token","expect":"did-you-mean:blocked","via":"guard:integer-cursor-vs-token"},
|
||||
{"command":"minutes tag query","emitted":"id","expect":"tag-id","via":"override:scoped-tag-id"},
|
||||
{"command":"minutes replace-text","emitted":"original-text","expect":"search","via":"override:scoped-search-role"},
|
||||
{"command":"minutes replace-text","emitted":"text","expect":"did-you-mean:blocked","via":"guard:search-vs-replacement-role"},
|
||||
{"command":"minutes +replace-batch","emitted":"pairs","expect":"pair","via":"override:scoped-pair-cardinality"},
|
||||
{"command":"minutes +replace-batch","emitted":"search","expect":"did-you-mean:blocked","via":"guard:requires-pair-value-composition"},
|
||||
{"command":"minutes +download","emitted":"destination","expect":"did-you-mean:ambiguous","via":"guard:output-role"},
|
||||
{"command":"minutes +detail","emitted":"url","expect":"did-you-mean:blocked","via":"guard:url-needs-value-extraction"},
|
||||
{"command":"minutes +apply-permission","emitted":"policy","expect":"did-you-mean:blocked","via":"guard:permission-value-conversion"},
|
||||
{"command":"minutes permission apply","emitted":"permission","expect":"did-you-mean:blocked","via":"guard:permission-value-conversion"},
|
||||
{"command":"minutes upload create","emitted":"file","expect":"did-you-mean:blocked","via":"guard:file-path-needs-orchestration"},
|
||||
{"command":"minutes +upload","emitted":"file-size","expect":"did-you-mean:blocked","via":"guard:file-metadata-vs-path"},
|
||||
{"command":"minutes record start","emitted":"upload-id","expect":"did-you-mean:blocked","via":"guard:session-value-domain"},
|
||||
{"command":"minutes +upload-and-analyze","emitted":"task-uuid","expect":"resume-id","via":"override:resume-task-uuid"},
|
||||
{"command":"minutes get info","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes get summary","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes get keywords","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes get transcription","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes get todos","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes get audio","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes mind-graph create","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes mind-graph status","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes permission apply","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"},
|
||||
{"command":"minutes permission add","emitted":"id","expect":"did-you-mean:blocked","via":"guard:list-vs-single-task-uuid"},
|
||||
{"command":"minutes permission remove","emitted":"id","expect":"did-you-mean:blocked","via":"guard:list-vs-single-task-uuid"},
|
||||
{"command":"minutes upload complete","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-upload-session"},
|
||||
{"command":"minutes upload cancel","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-upload-session"},
|
||||
{"command":"minutes record start","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes record pause","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes record resume","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes record stop","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes +record-start","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes +record-pause","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes +record-resume","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes +record-stop","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"},
|
||||
{"command":"minutes speaker replace","emitted":"target-uids","expect":"did-you-mean:blocked","via":"guard:single-target-user"},
|
||||
{"command":"minutes +speaker-replace","emitted":"target-uids","expect":"did-you-mean:blocked","via":"guard:single-target-user"},
|
||||
{"command":"minutes tag query","emitted":"tag-ids","expect":"did-you-mean:blocked","via":"guard:single-tag"},
|
||||
{"command":"minutes +speaker-insights","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-async-task"},
|
||||
{"command":"minutes +upload-and-analyze","emitted":"resume-ids","expect":"did-you-mean:blocked","via":"guard:single-resume-task"},
|
||||
{"command":"minutes upload create","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"},
|
||||
{"command":"minutes +upload","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"},
|
||||
{"command":"minutes +upload-and-analyze","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"},
|
||||
{"command":"todo task get","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"},
|
||||
{"command":"todo task get","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"},
|
||||
{"command":"todo task get","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"},
|
||||
{"command":"todo task get","emitted":"todo-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"},
|
||||
{"command":"todo task create-sub","emitted":"task-id","expect":"parent-id","via":"concept:todo_task_id+bind"},
|
||||
{"command":"todo task create-sub","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-parent-task-id"},
|
||||
{"command":"todo task create-sub","emitted":"parent-task-id","expect":"parent-id","via":"override:parent-task-id"},
|
||||
{"command":"todo task create-sub","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:parent-target-vs-child-title"},
|
||||
{"command":"todo task create","emitted":"task","expect":"title","via":"override:creation-title"},
|
||||
{"command":"todo +create","emitted":"task","expect":"title","via":"override:creation-title"},
|
||||
{"command":"todo +assign","emitted":"title","expect":"task","via":"override:creation-title"},
|
||||
{"command":"todo +assign-multi","emitted":"title","expect":"task","via":"override:creation-title"},
|
||||
{"command":"todo +assign","emitted":"assignee-name","expect":"to","via":"override:human-name"},
|
||||
{"command":"todo +assign-multi","emitted":"assignee-names","expect":"to","via":"override:human-name-list"},
|
||||
{"command":"todo +update","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:target-id-vs-title"},
|
||||
{"command":"todo task update","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:target-id-vs-title"},
|
||||
{"command":"todo +remind","emitted":"due","expect":"at","via":"override:deadline-to-at"},
|
||||
{"command":"todo +remind","emitted":"title","expect":"task","via":"override:creation-title"},
|
||||
{"command":"todo task create","emitted":"deadline","expect":"due","via":"concept:todo_due_time"},
|
||||
{"command":"todo +create","emitted":"deadline","expect":"due","via":"concept:todo_due_time"},
|
||||
{"command":"todo task add-executor","emitted":"executor-ids","expect":"executors","via":"concept:todo_executor_ids"},
|
||||
{"command":"todo task add-executor","emitted":"executor-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list-executor"},
|
||||
{"command":"todo task add-executor","emitted":"participant-id","expect":"did-you-mean:blocked","via":"guard:executor-vs-participant-role"},
|
||||
{"command":"todo +create","emitted":"executor-ids","expect":"executors","via":"concept:todo_executor_ids"},
|
||||
{"command":"todo task add-participant","emitted":"participant-ids","expect":"participants","via":"concept:todo_participant_ids"},
|
||||
{"command":"todo task add-participant","emitted":"participant-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list-participant"},
|
||||
{"command":"todo task add-participant","emitted":"executor-id","expect":"did-you-mean:blocked","via":"guard:participant-vs-executor-role"},
|
||||
{"command":"todo task update","emitted":"status","expect":"done","via":"concept:todo_completion_state"},
|
||||
{"command":"todo task update","emitted":"is-done","expect":"done","via":"concept:todo_completion_state"},
|
||||
{"command":"todo task done","emitted":"done","expect":"status","via":"concept:todo_completion_state"},
|
||||
{"command":"todo +search","emitted":"done","expect":"status","via":"concept:todo_completion_state"},
|
||||
{"command":"todo +search","emitted":"todo-status","expect":"status","via":"concept:todo_completion_state"},
|
||||
{"command":"todo +get-related-tasks","emitted":"todo-status","expect":"status","via":"override:enum-status"},
|
||||
{"command":"todo +get-related-tasks","emitted":"done","expect":"did-you-mean:blocked","via":"guard:boolean-vs-enum-status"},
|
||||
{"command":"todo +due-today","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"},
|
||||
{"command":"todo +get-my-tasks","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"},
|
||||
{"command":"todo +get-related-tasks","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"},
|
||||
{"command":"todo task list","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"},
|
||||
{"command":"todo +get-my-tasks","emitted":"priorities","expect":"priority","via":"override:priority-list"},
|
||||
{"command":"todo comment add","emitted":"text","expect":"content","via":"concept:content_text"},
|
||||
{"command":"todo +comment","emitted":"body","expect":"content","via":"concept:content_text"},
|
||||
{"command":"todo comment list","emitted":"page-size","expect":"size","via":"concept:pagination_size"},
|
||||
{"command":"todo +get-my-tasks","emitted":"current-page","expect":"page","via":"concept:page_number"},
|
||||
{"command":"todo +get-my-tasks","emitted":"page-number","expect":"page","via":"override:page-number"},
|
||||
{"command":"todo +get-my-tasks","emitted":"page-size","expect":"size","via":"override:per-page-size"},
|
||||
{"command":"todo +get-my-tasks","emitted":"max-results","expect":"did-you-mean:blocked","via":"guard:total-vs-per-page"},
|
||||
{"command":"todo +list-comment","emitted":"page-number","expect":"page","via":"override:page-number"},
|
||||
{"command":"todo comment list","emitted":"page-number","expect":"page","via":"override:page-number"},
|
||||
{"command":"todo task list","emitted":"limit","expect":"size","via":"override:limit"},
|
||||
{"command":"todo task list","emitted":"page-number","expect":"page","via":"override:page-number"},
|
||||
{"command":"todo +todo-done","emitted":"query","expect":"task","via":"override:query"},
|
||||
{"command":"todo +search","emitted":"keyword","expect":"query","via":"concept:search_query"},
|
||||
{"command":"todo +search","emitted":"title","expect":"query","via":"override:title-query"},
|
||||
{"command":"todo +search","emitted":"subject","expect":"query","via":"override:subject-query"},
|
||||
{"command":"todo +reminder","emitted":"reminder-time-stamp","expect":"at","via":"override:custom-reminder-time"},
|
||||
{"command":"todo task add-reminder","emitted":"at","expect":"reminder-time-stamp","via":"override:custom-reminder-time"},
|
||||
{"command":"todo +update","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"},
|
||||
{"command":"todo +complete","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"},
|
||||
{"command":"todo +reopen","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"},
|
||||
{"command":"todo +reminder","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"},
|
||||
{"command":"todo +todo-done","emitted":"task-id","expect":"did-you-mean:blocked","via":"guard:title-query-vs-task-id"},
|
||||
{"command":"todo task list","emitted":"plan-finish-start","expect":"did-you-mean:blocked","via":"guard:iso-vs-unix-millis"},
|
||||
{"command":"todo +get-my-tasks","emitted":"plan-finish-date-start","expect":"did-you-mean:blocked","via":"guard:unix-millis-vs-iso"},
|
||||
{"command":"todo +remind","emitted":"reminder-time-stamp","expect":"did-you-mean:blocked","via":"guard:deadline-vs-reminder"},
|
||||
{"command":"todo +reminder","emitted":"deadline","expect":"did-you-mean:blocked","via":"guard:deadline-vs-reminder"},
|
||||
{"command":"todo task reset-reminder","emitted":"due-date-offset","expect":"did-you-mean:blocked","via":"guard:atomic-vs-json-rules"},
|
||||
{"command":"todo tag update","emitted":"tag-codes","expect":"did-you-mean:blocked","via":"guard:flat-list-vs-json"},
|
||||
{"command":"todo +search","emitted":"page-size","expect":"did-you-mean:blocked","via":"guard:max-pages-vs-page-size"},
|
||||
{"command":"todo +complete","emitted":"status","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"},
|
||||
{"command":"todo +complete","emitted":"is-done","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"},
|
||||
{"command":"todo +reopen","emitted":"done","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"},
|
||||
{"command":"todo +reopen","emitted":"todo-status","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"},
|
||||
{"command":"wiki +node-copy","emitted":"node-id","expect":"node","via":"override:scoped_strict"},
|
||||
{"command":"wiki +move","emitted":"node-id","expect":"node","via":"override:scoped_strict"},
|
||||
{"command":"wiki +move-to-drive","emitted":"node-id","expect":"node","via":"override:scoped_strict"},
|
||||
{"command":"wiki +node-delete","emitted":"node-id","expect":"node","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-add","emitted":"user-id","expect":"user","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-add","emitted":"uid","expect":"user","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-update","emitted":"user-id","expect":"user","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-update","emitted":"uid","expect":"user","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-remove","emitted":"user-id","expect":"user","via":"override:scoped_strict"},
|
||||
{"command":"wiki +member-remove","emitted":"uid","expect":"user","via":"override:scoped_strict"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"])
|
||||
}
|
||||
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} {
|
||||
if parameters[flagName]["required"] != false {
|
||||
t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"])
|
||||
}
|
||||
@@ -315,6 +315,15 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
if parameters["max-depth"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"])
|
||||
}
|
||||
if got := parameters["version"]["property"]; got != "historyVersion" {
|
||||
t.Fatalf("doc read --version property = %#v, want historyVersion", got)
|
||||
}
|
||||
if parameters["version"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"])
|
||||
}
|
||||
if got := parameters["password"]["property"]; got != "password" {
|
||||
t.Fatalf("doc read --password property = %#v, want password", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
|
||||
@@ -76,6 +76,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"agoal scorecard detail",
|
||||
"agoal scorecard entity-detail",
|
||||
"agoal scorecard update",
|
||||
"agoal scorecard search-entities",
|
||||
"agoal strategy detail",
|
||||
"agoal strategy list",
|
||||
"agoal strategy update",
|
||||
|
||||
@@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error {
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
if err := validateSchemaValue("$", params, schema); err != nil {
|
||||
if err := ValidateJSONSchemaValue(params, schema); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJSONSchemaValue validates one decoded JSON value against the
|
||||
// required/type/enum/properties/items subset used by reviewed CLI contracts.
|
||||
func ValidateJSONSchemaValue(value any, schema map[string]any) error {
|
||||
return validateSchemaValue("$", value, schema)
|
||||
}
|
||||
|
||||
func validateSchemaValue(path string, value any, schema map[string]any) error {
|
||||
if len(schema) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package commentreaction validates the reviewed DingTalk names accepted by
|
||||
// comment emoji replies. The names mirror the bundled default emoji catalog;
|
||||
// arbitrary text and raw Unicode emoji must never be persisted as reactions.
|
||||
package commentreaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// Validate rejects values outside DingTalk's reviewed default reaction-name
|
||||
// catalog. Names are case-sensitive because they are sent verbatim to the
|
||||
// doc-comment service.
|
||||
func Validate(value string) error {
|
||||
name := strings.TrimSpace(value)
|
||||
if value != name || !utf8.ValidString(name) || !supported(name) {
|
||||
return apperrors.NewValidation(
|
||||
"--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本",
|
||||
apperrors.WithReason("unsupported_comment_reaction"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func supported(name string) bool {
|
||||
switch name {
|
||||
case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴",
|
||||
"睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳",
|
||||
"握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住",
|
||||
"比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病",
|
||||
"吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕",
|
||||
"骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见",
|
||||
"投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈",
|
||||
"灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰",
|
||||
"炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶",
|
||||
"发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头",
|
||||
"白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸",
|
||||
"Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注",
|
||||
"忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班",
|
||||
"退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬",
|
||||
"选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物",
|
||||
"生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶",
|
||||
"OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差",
|
||||
"高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音",
|
||||
"公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹",
|
||||
"烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package commentreaction
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageValidate(t *testing.T) {
|
||||
for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} {
|
||||
if err := Validate(value); err != nil {
|
||||
t.Errorf("supported reaction %q rejected: %v", value, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} {
|
||||
if err := Validate(value); err == nil {
|
||||
t.Errorf("unsupported reaction %q accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,9 +230,10 @@ const (
|
||||
// - Validate / PostMount — orchestration only; must not register business flags
|
||||
// or assemble business params that belong in Flags/ConstParams.
|
||||
//
|
||||
// Exactly one of RunE / Invoke / Orchestrate must be set; New validates this at
|
||||
// construction time. corecmd stays dispatch-agnostic and never calls a backend:
|
||||
// the adapters (FromLeafSpec / FromShortcut) supply the body.
|
||||
// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New
|
||||
// validates this at construction time. corecmd stays dispatch-agnostic and
|
||||
// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the
|
||||
// body.
|
||||
type Spec struct {
|
||||
Use string
|
||||
Short string
|
||||
@@ -259,7 +260,8 @@ type Spec struct {
|
||||
// backend call).
|
||||
ConfirmFirst bool
|
||||
// ConstParams are fixed toolArgs merged after flag assembly (e.g. precheckOnly).
|
||||
// They are payload declaration, not user flags, and never satisfy Required.
|
||||
// They are payload declaration, not user flags, never satisfy Required, and
|
||||
// require an Invoke or ResultInvoke dispatcher that consumes assembled args.
|
||||
ConstParams map[string]any
|
||||
// Contract is the authoring-time leaf contract declaration (selection /
|
||||
// interface / parameters / dry-run / identity). When non-empty, embed
|
||||
@@ -371,7 +373,9 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") }
|
||||
// malformed spec can never run the pipeline — write-confirmation prompt
|
||||
// included — and then silently exit 0 having done nothing.
|
||||
func New(spec Spec) *cobra.Command {
|
||||
spec.ConstParams = cloneConstParams(spec.ConstParams)
|
||||
validateDispatchDecl(spec)
|
||||
validateConstParamsDecl(spec)
|
||||
validateSafetySpec(spec)
|
||||
validateContractDecl(spec)
|
||||
validateInputSpecs(spec.Use, spec.Flags)
|
||||
@@ -399,6 +403,7 @@ func New(spec Spec) *cobra.Command {
|
||||
if spec.PostMount != nil {
|
||||
spec.PostMount(cmd)
|
||||
}
|
||||
attachInterfaceBoolConstParams(cmd, spec.ConstParams)
|
||||
if spec.OutputRollout != "" {
|
||||
output.SetCommandRollout(cmd, spec.OutputRollout)
|
||||
}
|
||||
@@ -462,6 +467,17 @@ func New(spec Spec) *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func cloneConstParams(params map[string]any) map[string]any {
|
||||
if params == nil {
|
||||
return nil
|
||||
}
|
||||
frozen := make(map[string]any, len(params))
|
||||
for key, value := range params {
|
||||
frozen[key] = value
|
||||
}
|
||||
return frozen
|
||||
}
|
||||
|
||||
// ConfirmFirstAnnotation marks commands whose Spec declared ConfirmFirst. The
|
||||
// delivery gate reads it to tell a declared guard-first command apart from an
|
||||
// accidental confirm-before-validate inversion: guard-first is only legitimate
|
||||
@@ -537,6 +553,21 @@ func validateDispatchDecl(spec Spec) {
|
||||
}
|
||||
}
|
||||
|
||||
func validateConstParamsDecl(spec Spec) {
|
||||
if len(spec.ConstParams) == 0 {
|
||||
return
|
||||
}
|
||||
if spec.Invoke == nil && spec.ResultInvoke == nil {
|
||||
panic(fmt.Sprintf("command %q ConstParams require Invoke or ResultInvoke", spec.Use))
|
||||
}
|
||||
for _, flag := range spec.Flags {
|
||||
key := bindKey(flag)
|
||||
if _, conflicts := spec.ConstParams[key]; conflicts {
|
||||
panic(fmt.Sprintf("command %q ConstParams key %q conflicts with flag --%s", spec.Use, key, flag.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateSafetySpec rejects partial safety declarations. A zero value remains
|
||||
// the historical read-only default, but once any field is authored all four
|
||||
// independent Schema dimensions must be explicit.
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
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/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -1415,17 +1416,23 @@ func TestCrossPlatformCoverageBuildArgsIntArgDefaultFloor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCommandMergesConstParams(t *testing.T) {
|
||||
func TestCrossPlatformCoverageNewCommandFreezesAndMergesConstParams(t *testing.T) {
|
||||
var got map[string]any
|
||||
declared := map[string]any{
|
||||
"precheckOnly": false,
|
||||
"convThreadEnabled": true,
|
||||
}
|
||||
cmd := New(Spec{
|
||||
Use: "pub",
|
||||
Flags: []FlagSpec{{Name: "id", Usage: "ID", Bind: "versionId", Trim: true}},
|
||||
ConstParams: map[string]any{"precheckOnly": false},
|
||||
ConstParams: declared,
|
||||
Invoke: func(_ *Ctx, toolArgs map[string]any) error {
|
||||
got = toolArgs
|
||||
return nil
|
||||
},
|
||||
})
|
||||
declared["precheckOnly"] = true
|
||||
declared["forgedAfterNew"] = true
|
||||
_ = cmd.Flags().Set("id", "V1")
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -1436,6 +1443,95 @@ func TestNewCommandMergesConstParams(t *testing.T) {
|
||||
if got["precheckOnly"] != false {
|
||||
t.Fatalf("precheckOnly = %#v, want false", got["precheckOnly"])
|
||||
}
|
||||
if got["convThreadEnabled"] != true {
|
||||
t.Fatalf("convThreadEnabled = %#v, want true", got["convThreadEnabled"])
|
||||
}
|
||||
if _, exists := got["forgedAfterNew"]; exists {
|
||||
t.Fatalf("caller mutation leaked into dispatch args: %#v", got)
|
||||
}
|
||||
evidence := InterfaceBoolConstParams(cmd)
|
||||
if !reflect.DeepEqual(evidence, map[string]bool{"convThreadEnabled": true, "precheckOnly": false}) {
|
||||
t.Fatalf("bool ConstParams evidence = %#v", evidence)
|
||||
}
|
||||
if got["precheckOnly"] != evidence["precheckOnly"] {
|
||||
t.Fatalf("dispatch/evidence drift: args=%#v evidence=%#v", got, evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewCommandDoesNotProjectMixedConstParamsEvidence(t *testing.T) {
|
||||
var got map[string]any
|
||||
cmd := New(Spec{
|
||||
Use: "mixed",
|
||||
ConstParams: map[string]any{
|
||||
"convThreadEnabled": true,
|
||||
"retryLimit": 3,
|
||||
},
|
||||
Invoke: func(_ *Ctx, toolArgs map[string]any) error {
|
||||
got = toolArgs
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
if evidence := InterfaceBoolConstParams(cmd); evidence != nil {
|
||||
t.Fatalf("mixed ConstParams evidence = %#v; want missing evidence", evidence)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["convThreadEnabled"] != true || got["retryLimit"] != 3 {
|
||||
t.Fatalf("mixed ConstParams dispatch args = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewCommandRejectsInvalidConstParamsDispatch(t *testing.T) {
|
||||
mustPanic := func(name string, spec Spec, needle string) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatalf("%s: expected panic", name)
|
||||
}
|
||||
if msg, _ := r.(string); !strings.Contains(msg, needle) {
|
||||
t.Fatalf("%s: panic=%v, want %q", name, r, needle)
|
||||
}
|
||||
}()
|
||||
New(spec)
|
||||
}
|
||||
|
||||
mustPanic("RunE", Spec{
|
||||
Use: "run-e",
|
||||
ConstParams: map[string]any{"fixed": true},
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}, "ConstParams require Invoke or ResultInvoke")
|
||||
mustPanic("Orchestrate", Spec{
|
||||
Use: "orchestrate",
|
||||
ConstParams: map[string]any{"fixed": true},
|
||||
Orchestrate: func(*Ctx) error { return nil },
|
||||
}, "ConstParams require Invoke or ResultInvoke")
|
||||
mustPanic("explicit bind conflict", Spec{
|
||||
Use: "bind-conflict",
|
||||
Flags: []FlagSpec{{Name: "thread", Usage: "T", Bind: "convThreadEnabled"}},
|
||||
ConstParams: map[string]any{"convThreadEnabled": true},
|
||||
Invoke: func(*Ctx, map[string]any) error { return nil },
|
||||
}, "conflicts with flag --thread")
|
||||
mustPanic("default bind conflict", Spec{
|
||||
Use: "default-bind-conflict",
|
||||
Flags: []FlagSpec{{Name: "fixed-value", Usage: "F"}},
|
||||
ConstParams: map[string]any{"fixedValue": true},
|
||||
Invoke: func(*Ctx, map[string]any) error { return nil },
|
||||
}, "conflicts with flag --fixed-value")
|
||||
|
||||
result := New(Spec{
|
||||
Use: "result",
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
ConstParams: map[string]any{"fixed": true},
|
||||
ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) {
|
||||
return output.Success(nil), nil
|
||||
},
|
||||
})
|
||||
if evidence := InterfaceBoolConstParams(result); !evidence["fixed"] {
|
||||
t.Fatalf("ResultInvoke ConstParams evidence = %#v", evidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewCommandConfirmFirstAnnotationAndOrder(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package corecmd
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var interfaceBoolConstParamsRegistry sync.Map
|
||||
|
||||
// attachInterfaceBoolConstParams records framework-owned ConstParams evidence.
|
||||
// Only a non-empty, entirely boolean declaration is representable in v1;
|
||||
// empty or mixed declarations remove any evidence for the command.
|
||||
func attachInterfaceBoolConstParams(cmd *cobra.Command, params map[string]any) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if len(params) == 0 {
|
||||
interfaceBoolConstParamsRegistry.Delete(cmd)
|
||||
return
|
||||
}
|
||||
bools := make(map[string]bool, len(params))
|
||||
for key, value := range params {
|
||||
boolValue, ok := value.(bool)
|
||||
if !ok {
|
||||
interfaceBoolConstParamsRegistry.Delete(cmd)
|
||||
return
|
||||
}
|
||||
bools[key] = boolValue
|
||||
}
|
||||
interfaceBoolConstParamsRegistry.Store(cmd, bools)
|
||||
}
|
||||
|
||||
// InterfaceBoolConstParams returns a clone of framework-owned boolean
|
||||
// ConstParams evidence. Callers cannot mutate the private registry.
|
||||
func InterfaceBoolConstParams(cmd *cobra.Command) map[string]bool {
|
||||
if cmd == nil {
|
||||
return nil
|
||||
}
|
||||
stored, ok := interfaceBoolConstParamsRegistry.Load(cmd)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
params := stored.(map[string]bool)
|
||||
clone := make(map[string]bool, len(params))
|
||||
for key, value := range params {
|
||||
clone[key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package corecmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageInterfaceBoolConstParamsRegistryClonesAndDeletes(t *testing.T) {
|
||||
attachInterfaceBoolConstParams(nil, map[string]any{"ignored": true})
|
||||
if got := InterfaceBoolConstParams(nil); got != nil {
|
||||
t.Fatalf("nil command evidence = %#v, want nil", got)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "send"}
|
||||
declared := map[string]any{"convThreadEnabled": true, "precheckOnly": false}
|
||||
attachInterfaceBoolConstParams(cmd, declared)
|
||||
declared["convThreadEnabled"] = false
|
||||
|
||||
want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false}
|
||||
got := InterfaceBoolConstParams(cmd)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("registered bool ConstParams = %#v, want %#v", got, want)
|
||||
}
|
||||
got["convThreadEnabled"] = false
|
||||
if again := InterfaceBoolConstParams(cmd); !reflect.DeepEqual(again, want) {
|
||||
t.Fatalf("reader leaked mutable registry state: %#v", again)
|
||||
}
|
||||
|
||||
attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true, "retryLimit": 3})
|
||||
if got := InterfaceBoolConstParams(cmd); got != nil {
|
||||
t.Fatalf("mixed ConstParams retained evidence: %#v", got)
|
||||
}
|
||||
|
||||
attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true})
|
||||
attachInterfaceBoolConstParams(cmd, nil)
|
||||
if got := InterfaceBoolConstParams(cmd); got != nil {
|
||||
t.Fatalf("empty ConstParams retained evidence: %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
|
||||
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_cc", EventOAApprovalInstanceCC, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
|
||||
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
|
||||
|
||||
@@ -180,6 +180,19 @@ type OAApprovalInstanceStartedOutput struct {
|
||||
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceCCOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批实例到达抄送节点时的状态"`
|
||||
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批抄送事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceTerminatedOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
@@ -667,6 +680,19 @@ func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.R
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceCC:
|
||||
return OAApprovalInstanceCCOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
return OAApprovalInstanceTerminatedOutput{
|
||||
Type: base.Type,
|
||||
@@ -871,6 +897,8 @@ func outputTypeForEvent(eventKey string) reflect.Type {
|
||||
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceStarted:
|
||||
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceCC:
|
||||
return reflect.TypeOf(OAApprovalInstanceCCOutput{})
|
||||
case eventKey == EventOAApprovalInstanceTerminated:
|
||||
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceFinished:
|
||||
@@ -906,6 +934,7 @@ func isOAEvent(eventKey string) bool {
|
||||
eventKey == EventOAApprovalTaskFinished ||
|
||||
eventKey == EventOAApprovalTaskRedirected ||
|
||||
eventKey == EventOAApprovalInstanceStarted ||
|
||||
eventKey == EventOAApprovalInstanceCC ||
|
||||
eventKey == EventOAApprovalInstanceTerminated ||
|
||||
eventKey == EventOAApprovalInstanceFinished
|
||||
}
|
||||
|
||||
@@ -173,6 +173,8 @@ func personalOAData(eventKey string) string {
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
case EventOAApprovalInstanceStarted:
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalInstanceCC:
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
body["status"] = "TERMINATED"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
@@ -521,6 +523,21 @@ func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceCC,
|
||||
want: OAApprovalInstanceCCOutput{
|
||||
Type: EventOAApprovalInstanceCC,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
Title: "测试审批",
|
||||
Status: "RUNNING",
|
||||
CreateTime: 1785229100000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
want: OAApprovalInstanceTerminatedOutput{
|
||||
@@ -785,6 +802,7 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
|
||||
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
|
||||
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
|
||||
EventOAApprovalInstanceCC = "user_oa_approval_instance_cc"
|
||||
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
|
||||
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
|
||||
)
|
||||
@@ -323,6 +324,17 @@ var definitions = []Definition{
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceCC,
|
||||
DisplayName: "审批单抄送",
|
||||
Description: "审批实例到达抄送节点,发送给被抄送人",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceTerminated,
|
||||
DisplayName: "审批单终止",
|
||||
|
||||
@@ -50,6 +50,7 @@ func TestCatalogEnabledEvents(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -65,6 +66,7 @@ func TestOAEventCatalogDefinitions(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -158,6 +160,7 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
@@ -509,6 +512,13 @@ func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceCC,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
@@ -633,6 +643,7 @@ func TestBuildRuleParamAllEvents(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
@@ -845,6 +856,7 @@ func TestSupportsMessageFilter(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
"unknown_event",
|
||||
|
||||
@@ -31,6 +31,7 @@ func newAgoalCommand() *cobra.Command {
|
||||
dws agoal scorecard detail 获取计分卡详情
|
||||
dws agoal scorecard entity-detail 获取计分卡实体详情
|
||||
dws agoal scorecard update 更新计分卡
|
||||
dws agoal scorecard search-entities 搜索计分卡指标与关键事项
|
||||
dws agoal user rules 获取用户规则
|
||||
dws agoal user objectives 查询用户目标列表
|
||||
dws agoal report list-statistics 获取周月报数据跟催列表
|
||||
@@ -359,7 +360,37 @@ scopeType 支持:
|
||||
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
|
||||
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
|
||||
scorecardSearchContentCmd := &cobra.Command{
|
||||
Use: "search-entities",
|
||||
Short: "搜索计分卡指标与关键事项",
|
||||
Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`,
|
||||
Example: ` dws agoal scorecard search-entities --keyword "业绩"
|
||||
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "keyword"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"keyword": mustGetFlag(cmd, "keyword"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
|
||||
toolArgs["page"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
|
||||
toolArgs["pageSize"] = v
|
||||
}
|
||||
return callMCPTool("search_score_card_entities", toolArgs)
|
||||
},
|
||||
}
|
||||
scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)")
|
||||
scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd)
|
||||
|
||||
// ── user: 用户目标管理 ──────────────────────────────────────
|
||||
|
||||
|
||||
+366
-3
@@ -7,6 +7,8 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -126,6 +128,83 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
|
||||
return dsl, nil
|
||||
}
|
||||
|
||||
// executeAitableWorkflowPublish executes a publish exactly once, then requires
|
||||
// the reviewed valid/flowId envelope before rendering any success output.
|
||||
// create_workflow is non-idempotent, so transport uncertainty is never retried.
|
||||
func executeAitableWorkflowPublish(toolName string, args map[string]any) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("aitable", toolName, args)
|
||||
}
|
||||
raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", toolName, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err != nil || envelope == nil {
|
||||
return fmt.Errorf("%s response is not a JSON object", toolName)
|
||||
}
|
||||
valid, validFound, flowID, err := strictAitableWorkflowPublishResult(envelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s response validation failed: %w", toolName, err)
|
||||
}
|
||||
if !validFound {
|
||||
return fmt.Errorf("%s response is missing valid", toolName)
|
||||
}
|
||||
if !valid {
|
||||
return fmt.Errorf("%s returned valid=false; inspect issues and correct the workflow DSL", toolName)
|
||||
}
|
||||
if flowID == "" {
|
||||
return fmt.Errorf("%s response is missing a non-empty flowId", toolName)
|
||||
}
|
||||
return RenderLegacyMCPText(toolName, raw)
|
||||
}
|
||||
|
||||
func strictAitableWorkflowPublishResult(envelope map[string]any) (valid bool, validFound bool, flowID string, err error) {
|
||||
var visit func(map[string]any) error
|
||||
visit = func(object map[string]any) error {
|
||||
if raw, exists := object["valid"]; exists {
|
||||
value, ok := raw.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("valid must be boolean, got %T", raw)
|
||||
}
|
||||
if validFound && valid != value {
|
||||
return fmt.Errorf("conflicting valid values")
|
||||
}
|
||||
valid, validFound = value, true
|
||||
}
|
||||
for _, key := range []string{"flowId", "workflowId"} {
|
||||
raw, exists := object[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
value = strings.TrimSpace(value)
|
||||
if !ok || value == "" {
|
||||
return fmt.Errorf("%s must be a non-empty string", key)
|
||||
}
|
||||
if flowID != "" && flowID != value {
|
||||
return fmt.Errorf("conflicting workflow IDs")
|
||||
}
|
||||
flowID = value
|
||||
}
|
||||
for _, key := range []string{"data", "result", "response"} {
|
||||
if nested, ok := object[key].(map[string]any); ok {
|
||||
if err := visit(nested); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if envelope == nil {
|
||||
return false, false, "", fmt.Errorf("empty response")
|
||||
}
|
||||
if err := visit(envelope); err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
return valid, validFound, flowID, nil
|
||||
}
|
||||
|
||||
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
|
||||
tableID, _ := cmd.Flags().GetString("table-id")
|
||||
tableID = strings.TrimSpace(tableID)
|
||||
@@ -1184,6 +1263,290 @@ func runAitableViewUpdateArray(cmd *cobra.Command, blockKey string) error {
|
||||
return callUpdateViewWithBlock(baseID, tableID, viewID, blockKey, cfgMap[blockKey], nil)
|
||||
}
|
||||
|
||||
func runAitableViewUpdateFilter(cmd *cobra.Command) error {
|
||||
baseID, tableID, viewID, _, err := viewUpdateCommonPreflight(cmd, "filter", nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jsonStr, _ := cmd.Flags().GetString("json")
|
||||
if jsonStr == "" {
|
||||
return fmt.Errorf("必须指定 --json 传入 filter JSON 数组")
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
||||
return fmt.Errorf("--json 解析失败: %v", err)
|
||||
}
|
||||
cfgMap := map[string]any{"filter": parsed}
|
||||
if err := normalizeViewConfigBlock(cfgMap); err != nil {
|
||||
return err
|
||||
}
|
||||
filter, _ := cfgMap["filter"].([]any)
|
||||
fieldTypes, err := loadAitableFieldTypes(context.Background(), baseID, tableID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAitableViewFilter(filter, fieldTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "viewId": viewID,
|
||||
"config": map[string]any{"filter": filter},
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true, "executed": false, "tool": "update_view", "arguments": toolArgs,
|
||||
})
|
||||
}
|
||||
if _, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", "update_view", toolArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
var actual any
|
||||
var readBackErr error
|
||||
for attempt := 0; attempt < aitableViewFilterReadbackAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<(attempt-1)) * time.Second
|
||||
if backoff > 8*time.Second {
|
||||
backoff = 8 * time.Second
|
||||
}
|
||||
aitableViewFilterReadbackSleep(backoff)
|
||||
}
|
||||
view, _, err := getViewRaw(context.Background(), baseID, tableID, viewID)
|
||||
if err != nil {
|
||||
readBackErr = err
|
||||
continue
|
||||
}
|
||||
actualViewID, _ := view["viewId"].(string)
|
||||
if actualViewID != viewID {
|
||||
readBackErr = fmt.Errorf("update_view read-back returned viewId %q, want %q", actualViewID, viewID)
|
||||
continue
|
||||
}
|
||||
actual = walkViewPath(view, "filter")
|
||||
if persistedViewFilterMatches(actual, filter) {
|
||||
readBackErr = nil
|
||||
break
|
||||
}
|
||||
readBackErr = fmt.Errorf("update_view filter read-back mismatch: got %s, want %s", compactJSON(actual), compactJSON(filter))
|
||||
}
|
||||
if readBackErr != nil {
|
||||
return &CLIError{Code: CodeMCPToolError, Message: readBackErr.Error(), Suggestion: "重新读取 view get filter,确认服务端支持所用字段类型和操作符后再试"}
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"data": map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "filter": filter, "verified": true},
|
||||
})
|
||||
}
|
||||
|
||||
const aitableViewFilterReadbackAttempts = 6
|
||||
|
||||
var aitableViewFilterReadbackSleep = time.Sleep
|
||||
|
||||
func persistedViewFilterMatches(actual any, expected []any) bool {
|
||||
if reflect.DeepEqual(actual, expected) {
|
||||
return true
|
||||
}
|
||||
root, ok := actual.(map[string]any)
|
||||
if !ok || root["operator"] != "and" {
|
||||
return false
|
||||
}
|
||||
operands, ok := root["operands"].([]any)
|
||||
return ok && reflect.DeepEqual(operands, expected)
|
||||
}
|
||||
|
||||
func loadAitableFieldTypes(ctx context.Context, baseID, tableID string) (map[string]string, error) {
|
||||
raw, err := callMCPReadToolReturnTextOnServer(ctx, "aitable", "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, fmt.Errorf("get_fields response is not valid JSON: %v", err)
|
||||
}
|
||||
fields, ok := findAitableObjectList(payload, "fields", "fieldList")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("get_fields response is missing the fields collection")
|
||||
}
|
||||
types := make(map[string]string, len(fields))
|
||||
for index, field := range fields {
|
||||
fieldID, _ := field["fieldId"].(string)
|
||||
if strings.TrimSpace(fieldID) == "" {
|
||||
fieldID, _ = field["id"].(string)
|
||||
}
|
||||
fieldType, _ := field["type"].(string)
|
||||
if strings.TrimSpace(fieldType) == "" {
|
||||
fieldType, _ = field["fieldType"].(string)
|
||||
}
|
||||
if strings.TrimSpace(fieldID) == "" || strings.TrimSpace(fieldType) == "" {
|
||||
return nil, fmt.Errorf("get_fields field %d is missing fieldId or type", index)
|
||||
}
|
||||
types[strings.TrimSpace(fieldID)] = strings.TrimSpace(fieldType)
|
||||
}
|
||||
return types, nil
|
||||
}
|
||||
|
||||
func findAitableObjectList(value any, names ...string) ([]map[string]any, bool) {
|
||||
wanted := make([]string, 0, len(names))
|
||||
seen := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.ToLower(name)
|
||||
if !seen[name] {
|
||||
wanted = append(wanted, name)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
var walk func(any) ([]map[string]any, bool)
|
||||
walk = func(current any) ([]map[string]any, bool) {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, name := range wanted {
|
||||
for _, key := range keys {
|
||||
if !strings.EqualFold(key, name) {
|
||||
continue
|
||||
}
|
||||
items, ok := typed[key].([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, object)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
if found, ok := walk(typed[key]); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if found, ok := walk(child); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return walk(value)
|
||||
}
|
||||
|
||||
func validateAitableViewFilter(filter []any, fieldTypes map[string]string) error {
|
||||
for index, raw := range filter {
|
||||
condition, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("filter[%d] must be an object", index)
|
||||
}
|
||||
if err := validateAitableViewFilterCondition(condition, fieldTypes); err != nil {
|
||||
return fmt.Errorf("filter[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map[string]string) error {
|
||||
operator, _ := condition["operator"].(string)
|
||||
operator = strings.TrimSpace(operator)
|
||||
if operator == "" || !validFilterOperators[operator] {
|
||||
return fmt.Errorf("unsupported operator %q", operator)
|
||||
}
|
||||
operands, ok := condition["operands"].([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("operator %s requires an operands array", operator)
|
||||
}
|
||||
if operator == "and" || operator == "or" {
|
||||
return fmt.Errorf("logical operator %s is not supported by the persisted view protocol; pass a flat array of leaf conditions (combined as AND)", operator)
|
||||
}
|
||||
wantOperands := 2
|
||||
if operator == "exist" || operator == "un_exist" {
|
||||
wantOperands = 1
|
||||
}
|
||||
if len(operands) != wantOperands {
|
||||
return fmt.Errorf("operator %s requires %d operands", operator, wantOperands)
|
||||
}
|
||||
fieldID, ok := operands[0].(string)
|
||||
fieldID = strings.TrimSpace(fieldID)
|
||||
if !ok || fieldID == "" {
|
||||
return fmt.Errorf("operator %s requires a fieldId as its first operand", operator)
|
||||
}
|
||||
fieldType, exists := fieldTypes[fieldID]
|
||||
if !exists {
|
||||
return fmt.Errorf("filter references unknown fieldId %q", fieldID)
|
||||
}
|
||||
if isAitableDateFieldType(fieldType) && !isAitableDateFilterOperator(operator) {
|
||||
return fmt.Errorf("operator %s is invalid for %s field %s; use date_eq/before/after/not_before/not_after/exist/un_exist", operator, fieldType, fieldID)
|
||||
}
|
||||
if operator == "any_of" || operator == "all_of" || operator == "none_of" {
|
||||
if !strings.EqualFold(fieldType, "multipleSelect") && !strings.EqualFold(fieldType, "multiSelect") {
|
||||
return fmt.Errorf("operator %s requires a multipleSelect field, got %s", operator, fieldType)
|
||||
}
|
||||
if operator == "any_of" {
|
||||
if values, ok := operands[1].([]any); ok {
|
||||
if err := validateAitableMultiSelectOptionNames(values); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("multipleSelect any_of with multiple values is not supported by the persisted view protocol; use one scalar option or separate views")
|
||||
}
|
||||
}
|
||||
if err := validateAitableMultiSelectFilterValue(operands[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableMultiSelectOptionNames(values []any) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("multipleSelect any_of array must not be empty")
|
||||
}
|
||||
for index, value := range values {
|
||||
text, ok := value.(string)
|
||||
text = strings.TrimSpace(text)
|
||||
if !ok || text == "" {
|
||||
return fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isAitableDateFieldType(fieldType string) bool {
|
||||
return strings.EqualFold(fieldType, "date") ||
|
||||
strings.EqualFold(fieldType, "createdTime") ||
|
||||
strings.EqualFold(fieldType, "lastModifiedTime")
|
||||
}
|
||||
|
||||
func isAitableDateFilterOperator(operator string) bool {
|
||||
switch operator {
|
||||
case "date_eq", "before", "after", "not_before", "not_after", "exist", "un_exist":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateAitableMultiSelectFilterValue(value any) error {
|
||||
if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("multipleSelect filter value must be one option-name string; the persisted view protocol does not support a multi-value OR expression")
|
||||
}
|
||||
|
||||
func compactJSON(value any) string {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%#v", value)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func newAitableCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/aitable.json
|
||||
// products.aitable). Catalog assembly stamps provenance contract_final.
|
||||
@@ -4076,7 +4439,7 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
|
||||
若传对象会自动 wrap 为数组;其他非法格式拒绝。`,
|
||||
Example: ` dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runAitableViewUpdateArray(cmd, "filter")
|
||||
return runAitableViewUpdateFilter(cmd)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(viewUpdateFilterCmd, LeafSpec{
|
||||
@@ -5282,7 +5645,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
}
|
||||
// create_workflow is non-idempotent. Bypass the retry wrapper to
|
||||
// prevent an uncertain first response from creating a duplicate.
|
||||
return callMCPToolOnServer("aitable", "create_workflow", toolArgs)
|
||||
return executeAitableWorkflowPublish("create_workflow", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(workflowCreateCmd, LeafSpec{
|
||||
@@ -5376,7 +5739,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
|
||||
toolArgs["locale"] = locale
|
||||
}
|
||||
return callAitableTool("update_workflow", toolArgs)
|
||||
return executeAitableWorkflowPublish("update_workflow", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(workflowUpdateCmd, LeafSpec{
|
||||
|
||||
@@ -361,3 +361,228 @@ func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) {
|
||||
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing.T) {
|
||||
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
os.Args = []string{"dws", "aitable"}
|
||||
filter := `[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]`
|
||||
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"},{"fieldId":"fldB","type":"text"},{"fieldId":"fldMulti","type":"multipleSelect"}]}}`
|
||||
readBack := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]}]}}`
|
||||
|
||||
t.Run("flat leaf filters are written then exactly verified", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, readBack}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
|
||||
if err != nil || len(caller.calls) != 3 || caller.calls[0].tool != "get_fields" || caller.calls[1].tool != "update_view" || caller.calls[2].tool != "get_views" {
|
||||
t.Fatalf("verified view filter = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
config, _ := caller.calls[1].args["config"].(map[string]any)
|
||||
if _, ok := config["filter"].([]any); !ok {
|
||||
t.Fatalf("update_view filter encoding = %#v", caller.calls[1].args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logical groups fail closed before write", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"or","operands":[{"operator":"eq","operands":["fldA","x"]},{"operator":"eq","operands":["fldB","y"]}]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("logical filter group fail-closed = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown field is rejected before write", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"eq","operands":["missing","x"]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown fieldId") || len(caller.calls) != 1 {
|
||||
t.Fatalf("unknown filter field = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("eventual persisted and wrapper is retried then verified", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti","A"]}]`
|
||||
stale := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[]}}]}}`
|
||||
terminal := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[{"operator":"any_of","operands":["fldMulti","A"]}]}}]}}`
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, stale, terminal}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err != nil || len(caller.calls) != 4 || caller.calls[2].tool != "get_views" || caller.calls[3].tool != "get_views" {
|
||||
t.Fatalf("eventual wrapped filter = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select operator rejects text field", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"any_of","operands":["fldA",["A"]]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a multipleSelect field") || len(caller.calls) != 1 {
|
||||
t.Fatalf("wrong filter field type = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select array fails closed before write", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti",["A","B"]]}]`
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("multi-select array fail-closed = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select invalid array value fails before write", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti",["A",""]]}]`
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty option-name") || len(caller.calls) != 1 {
|
||||
t.Fatalf("invalid multi-select array = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date and system-time fields require date operators", func(t *testing.T) {
|
||||
fieldTypes := map[string]string{"date": "date", "created": "createdTime", "modified": "lastModifiedTime"}
|
||||
for fieldID := range fieldTypes {
|
||||
valid := []any{map[string]any{"operator": "date_eq", "operands": []any{fieldID, "2026-08-18"}}}
|
||||
if err := validateAitableViewFilter(valid, fieldTypes); err != nil {
|
||||
t.Fatalf("date_eq for %s: %v", fieldID, err)
|
||||
}
|
||||
invalid := []any{map[string]any{"operator": "eq", "operands": []any{fieldID, "2026-08-18"}}}
|
||||
if err := validateAitableViewFilter(invalid, fieldTypes); err == nil || !strings.Contains(err.Error(), "invalid for") {
|
||||
t.Fatalf("eq for %s = %v, want date-operator error", fieldID, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry-run validates but performs no write or read-back", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}, dryRun: true}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("view filter dry-run = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read-back mismatch is not success", func(t *testing.T) {
|
||||
responses := []string{fields, `{"success":true}`}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
responses = append(responses, `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[]}]}}`)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: responses}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
|
||||
if err == nil || !strings.Contains(err.Error(), "read-back mismatch") || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
|
||||
t.Fatalf("mismatched filter readback = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T) {
|
||||
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "aitable"}
|
||||
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"}]}}`
|
||||
filter := `[{"operator":"eq","operands":["fldA","x"]}]`
|
||||
args := []string{"view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json=" + filter}
|
||||
|
||||
t.Run("update transport error", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}, errors: []error{nil, context.Canceled}}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 2 {
|
||||
t.Fatalf("update error = %v, calls=%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("readback transport errors exhaust", func(t *testing.T) {
|
||||
errs := []error{nil, nil}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
errs = append(errs, context.DeadlineExceeded)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`}, errors: errs}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
|
||||
t.Fatalf("readback errors = %v, calls=%d", err, len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("readback wrong identity exhausts", func(t *testing.T) {
|
||||
responses := []string{fields, `{"success":true}`}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
responses = append(responses, `{"data":{"views":[{"viewId":"other","filter":[]}]}}`)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: responses}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), "returned viewId") {
|
||||
t.Fatalf("wrong readback identity = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
loadCases := []struct {
|
||||
name string
|
||||
response string
|
||||
callErr error
|
||||
wantError string
|
||||
}{
|
||||
{name: "transport", callErr: context.Canceled, wantError: "context canceled"},
|
||||
{name: "invalid json", response: `{`, wantError: "not valid JSON"},
|
||||
{name: "missing collection", response: `{}`, wantError: "missing the fields collection"},
|
||||
{name: "missing identity", response: `{"fields":[{"type":"text"}]}`, wantError: "missing fieldId or type"},
|
||||
}
|
||||
for _, tc := range loadCases {
|
||||
t.Run("load fields "+tc.name, func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{tc.response}, errors: []error{tc.callErr}}
|
||||
installAitableDeps(t, caller)
|
||||
if _, err := loadAitableFieldTypes(context.Background(), "b", "t"); err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("load fields error = %v, want %q", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("load legacy field keys", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{`{"fieldList":[{"id":"legacy","fieldType":"text"}]}`}}
|
||||
installAitableDeps(t, caller)
|
||||
got, err := loadAitableFieldTypes(context.Background(), "b", "t")
|
||||
if err != nil || got["legacy"] != "text" {
|
||||
t.Fatalf("legacy field keys = %#v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
if _, ok := findAitableObjectList(map[string]any{"fields": "bad"}, "fields"); ok {
|
||||
t.Fatal("scalar fields collection must fail")
|
||||
}
|
||||
if _, ok := findAitableObjectList(map[string]any{"fields": []any{"bad"}}, "fields"); ok {
|
||||
t.Fatal("scalar field item must fail")
|
||||
}
|
||||
if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 {
|
||||
t.Fatalf("recursive fields = %#v, %v", got, ok)
|
||||
}
|
||||
if got, ok := findAitableObjectList(map[string]any{
|
||||
"fieldList": []any{map[string]any{"fieldId": "legacy"}},
|
||||
"fields": []any{map[string]any{"fieldId": "canonical"}},
|
||||
}, "fields", "fieldList"); !ok || len(got) != 1 || got[0]["fieldId"] != "canonical" {
|
||||
t.Fatalf("declared collection priority = %#v, %v", got, ok)
|
||||
}
|
||||
|
||||
invalidFilters := []struct {
|
||||
filter []any
|
||||
want string
|
||||
}{
|
||||
{filter: []any{"bad"}, want: "must be an object"},
|
||||
{filter: []any{map[string]any{"operator": "bogus", "operands": []any{}}}, want: "unsupported operator"},
|
||||
{filter: []any{map[string]any{"operator": "eq", "operands": "bad"}}, want: "requires an operands array"},
|
||||
{filter: []any{map[string]any{"operator": "and", "operands": []any{}}}, want: "logical operator"},
|
||||
{filter: []any{map[string]any{"operator": "exist", "operands": []any{"f", "extra"}}}, want: "requires 1 operands"},
|
||||
{filter: []any{map[string]any{"operator": "eq", "operands": []any{1, "x"}}}, want: "requires a fieldId"},
|
||||
{filter: []any{map[string]any{"operator": "any_of", "operands": []any{"multi", 1}}}, want: "one option-name string"},
|
||||
}
|
||||
for _, tc := range invalidFilters {
|
||||
if err := validateAitableViewFilter(tc.filter, map[string]string{"f": "text", "multi": "multipleSelect"}); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want)
|
||||
}
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames(nil); err == nil {
|
||||
t.Fatal("empty any_of array must fail")
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames([]any{"ok", 1}); err == nil {
|
||||
t.Fatal("non-string any_of option must fail")
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames([]any{"first", " second "}); err != nil {
|
||||
t.Fatalf("valid any_of option names = %v", err)
|
||||
}
|
||||
if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") {
|
||||
t.Fatalf("compactJSON fallback = %q", got)
|
||||
}
|
||||
if persistedViewFilterMatches("bad", nil) || persistedViewFilterMatches(map[string]any{"operator": "or"}, nil) {
|
||||
t.Fatal("invalid persisted wrapper must not match")
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type aitableTestCaller struct {
|
||||
responses []string
|
||||
errors []error
|
||||
calls []aitableTestCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -38,8 +39,11 @@ func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, arg
|
||||
}
|
||||
return textToolResult(response), nil
|
||||
}
|
||||
func (c *aitableTestCaller) CallReadTool(ctx context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, server, tool, args)
|
||||
}
|
||||
func (*aitableTestCaller) Format() string { return "json" }
|
||||
func (*aitableTestCaller) DryRun() bool { return false }
|
||||
func (c *aitableTestCaller) DryRun() bool { return c.dryRun }
|
||||
func (*aitableTestCaller) Fields() string { return "" }
|
||||
func (*aitableTestCaller) JQ() string { return "" }
|
||||
|
||||
|
||||
@@ -23,27 +23,42 @@ type aitableWorkflowCall struct {
|
||||
}
|
||||
|
||||
type aitableWorkflowCaller struct {
|
||||
calls []aitableWorkflowCall
|
||||
calls []aitableWorkflowCall
|
||||
response string
|
||||
err error
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
response := c.response
|
||||
if response == "" {
|
||||
response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`,
|
||||
Text: response,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (*aitableWorkflowCaller) Format() string { return "json" }
|
||||
func (*aitableWorkflowCaller) DryRun() bool { return false }
|
||||
func (c *aitableWorkflowCaller) DryRun() bool { return c.dryRun }
|
||||
func (*aitableWorkflowCaller) Fields() string { return "" }
|
||||
func (*aitableWorkflowCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
|
||||
t.Helper()
|
||||
caller := &aitableWorkflowCaller{}
|
||||
return caller, runAitableWorkflowCommandWithCaller(t, caller, stdin, args...)
|
||||
}
|
||||
|
||||
func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCaller, stdin io.Reader, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableWorkflowCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
|
||||
@@ -51,6 +66,7 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
|
||||
cmd := newAitableCommand()
|
||||
cmd.PersistentFlags().String("format", "json", "output format")
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
cmd.PersistentFlags().Bool("dry-run", false, "preview only")
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append([]string{"workflow"}, args...))
|
||||
@@ -58,10 +74,10 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
|
||||
stdin = strings.NewReader("")
|
||||
}
|
||||
cmd.SetIn(stdin)
|
||||
return caller, cmd.Execute()
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
wantDSL := map[string]any{
|
||||
"version": "workflow-dsl/v1",
|
||||
"name": "create test",
|
||||
@@ -92,6 +108,65 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want string
|
||||
}{
|
||||
{name: "valid false", response: `{"status":"success","data":{"valid":false,"issues":[{"message":"bad dsl"}]}}`, want: "valid=false"},
|
||||
{name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"},
|
||||
{name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"},
|
||||
{name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"},
|
||||
{name: "malformed response", response: `{`, want: "not a JSON object"},
|
||||
{name: "null response", response: `null`, want: "not a JSON object"},
|
||||
{name: "conflicting valid", response: `{"valid":true,"data":{"valid":false,"flowId":"flow-test"}}`, want: "conflicting valid values"},
|
||||
{name: "invalid flow id", response: `{"valid":true,"flowId":1}`, want: "flowId must be a non-empty string"},
|
||||
{name: "conflicting workflow ids", response: `{"valid":true,"flowId":"one","data":{"workflowId":"two"}}`, want: "conflicting workflow IDs"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{response: tc.response}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || len(caller.calls) != 1 {
|
||||
t.Fatalf("workflow false success = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("update uses the same strict contract", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{response: `{"status":"success","data":{"valid":false}}`}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"update", "--base-id", "base", "--workflow-id", "flow", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "valid=false") || len(caller.calls) != 1 || caller.calls[0].toolName != "update_workflow" {
|
||||
t.Fatalf("workflow update false success = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry-run does not call publish tool", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{dryRun: true}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transport error", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{err: context.Canceled}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 1 {
|
||||
t.Fatalf("workflow transport error = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
if _, _, _, err := strictAitableWorkflowPublishResult(nil); err == nil || !strings.Contains(err.Error(), "empty response") {
|
||||
t.Fatalf("nil workflow envelope = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
|
||||
if err != nil {
|
||||
|
||||
Loaded 100 of 407 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user