Compare commits

...
23 Commits
Author SHA1 Message Date
修雨 6b4d808d39 docs(changelog): add 1.0.31 release notes (#338) 2026-05-21 20:22:57 +08:00
修雨andClaude Opus 4.7 c7d8ddf98d feat: align drive with wukong — helper (upload) + skill docs (doc/sheet dingpan URL) (#335)
* feat(helpers): drive upload 三步合成胶水(list-spaces / delete 由 envelope 接管)

业务背景:开源版 dws drive 仅有来自服务发现 envelope 的 6 个工具,悟空版多
3 个能力 — list-spaces / upload / delete。这 3 个里只有 upload 是真正的客户端
胶水(PUT 文件二进制到 OSS 的中间 HTTP 步),其它两个完全可以通过 envelope
toolOverrides 表达,无需 Go 代码。

按 envelope 优先原则拆分:

1. envelope/pre-discovery.pre.json (单独提供 / 不在本 commit)
   - drive.toolOverrides.list_spaces  →  cliName: list-spaces
   - drive.toolOverrides.delete_document → cliName: delete, serverOverride: doc,
                                             isSensitive: true
2. internal/helpers/drive.go (本 commit)
   - 仅注册 upload 一个 leaf
   - 原因:envelope.PipelineStep 只支持 type:"call"/"download",没有
     type:"upload"。客户端流式 PUT 本地文件到 OSS 签名 URL(含 per-URL
     headers)当前 envelope schema 表达不了,故保留为 helper。

internal/helpers/drive.go (新增 ~290 行)

  - upload — 三步合成:
    1) drive get_upload_info → resourceUrl + uploadId + headers
    2) HTTP PUT 文件二进制 → OSS(沿用 aitable upload-file 模式,
       10min timeout,跟随 per-URL headers)
    3) drive commit_upload → 提交入库
    --dry-run 输出三步 invocation 预览不实际请求。
    parseDriveUploadInfo 兼容 content/result 包裹层与 resourceUrls 数组
    及 flat resourceUrl/uploadUrl fallback,与悟空侧 parseDriveUploadInfo
    等价。
    validateDriveParentID 拒纯数字 dentryId(防混淆 chat 链路 dentryId 与
    drive 链路 dentryUuid)。

注入路径:helpers.RegisterPublic → pickCommands.MergeHardcodedLeaves(dynamic,
helper)(dynamic 赢冲突、helper 填空白)。本 helper root.Use="drive" 不覆盖
dynamic 的 6 个 leaf,仅追加 upload。

dry-run 实测:

  $ dws drive upload --file /etc/hosts --dry-run --format json
  → step_1_get_upload_info + step_2_http_put_oss + step_3_commit_upload 三步预览

测试结果:
  ok  internal/helpers (含全部既有 TestSendByBot* / TestAtomicWrite* /
                       TestValidate* 等 23 个测试)
  ok  test/integration/extensions
  test/cli_compat pre-existing failures 与本改动无关(在干净 origin/main HEAD
  同样 fail,fixture 缺失等)。

未来工作 (follow-up):
  - 等 envelope 文件 commit 到主线后,list-spaces / delete 自动从 envelope
    生成 cobra command,无需此 helper 操心
  - 如果 envelope schema 加入 type:"upload" pipeline 步类型,本 helper 整个
    可以删除,由 envelope pipeline 表达完整三步

关联:
  - 悟空源头实现:dws-wukong/wukong/products/drive.go
    (runDriveUpload / parseDriveUploadInfo / httpPutFile + delete 路由)
  - 配套 skill 文档 PR:#333 (doc 端钉盘 URL 识别)
  - envelope 改动: drive.toolOverrides 增加 list_spaces + delete_document,
    见对应的 envelope PR / commit

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(skill): doc --node 接受钉盘 document/edit|preview?dentryKey URL + 新增 url-patterns 分流

业务背景:hho 业务侧用户在悟空版上已用钉盘 URL(alidocs.dingtalk.com/document/edit?dentryKey=...)
直接喂给 Agent,但开源版 skill 文档未覆盖这种格式,Agent 不知道整段 URL 应原样传给 --node,
经常误把 dentryKey 当成裸 nodeId 调用导致失败。

本 PR 把悟空 dws-wukong PR #82157526 的 skill 文档增量移植到开源主线,保持 doc/sheet
两端入口的 URL 识别策略对齐悟空:

skills/SKILL.md
- 核心流程新增 Step 0「URL 预检」:含 alidocs URL 必须先读 url-patterns.md 分流,再选择产品
- 详细参考目录加入 url-patterns.md 引用

skills/references/products/doc.md
- dws doc info/read 的 Example 各 +2 行钉盘 URL 示例
- 「URL 识别与 DOC_ID 提取」支持的 URL 格式表新增 document/edit|preview?dentryKey={key} 一行
- 提取规则拆成 3 条,禁止 Agent 自行提取 dentryKey 当裸 nodeId
- 「nodeId 双格式说明」升级为「nodeId 多格式说明」,给出 4 种 --node 输入等价示例

skills/references/url-patterns.md (新建)
- 沉淀 alidocs URL 5 类分流决策:/i/p/ 短链 / /i/nodes/ 节点 / /spreadsheetv2/ 直链 /
  /document/edit|preview?dentryKey 链接 / 其他
- 含 i/nodes/ 类型探测流程(doc info 探 contentType/extension/nodeType 后再选产品)
- 含分享短链 read_url 兜底处理 + 动态渲染失败时的标准答复

skill 命名形态兼容:
- 当前 main 上 skills/ 是扁平结构,改动直接落在 skills/* 路径
- 单点(mono)/ 多点(multi)拆分形态在 feature/skill-setup 分支,待该分支合主线时
  按相同语义同步到 skills/mono/* 和 skills/multi/dingtalk-{doc,sheet}/* 两套

不涉及 Go 代码变更,纯 skill 文档;钉盘 URL 解析逻辑由 MCP 服务端承担。

关联悟空 PR:dws-wukong commit 565c63f8 / 712da968(#82157526)
对应 wukong skill 包:dingtalk-workspace/{SKILL.md,references/products/doc.md,references/url-patterns.md}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:38:00 +08:00
修雨 754b0df056 docs(changelog): add 1.0.30 release notes (#322)
* docs(changelog): add 1.0.30 release notes

Notes for the wukong-IM-envelope alignment + schema-pipeline / transform /
market enhancements that landed via #317. Also extends the README "Pipe &
File Input" section with the `@<text>` ASCII-prefix rule that lets literal
Chinese mentions like `@所有人` / `@张三` pass through unchanged.

Validated against `dws-wukong/auto-test/cli_to_mcp` with `--edition open`,
account wukong01, on this main:
- chat: 107 passed / 0 failed / 0 errored → 100.0% PASS
- report: auto-test/cli_to_mcp/open_report/test_report_20260519_155449.md

* docs(changelog,readme): drop internal validation note; refresh chat row

- CHANGELOG: remove the validation paragraph from the 1.0.30 entry — it
  cited an internal repo path, a test account, and a local-only report
  file, none of which belong in public release notes.
- README / README_zh: bump the Chat / IM row in the Key Services table
  from 23 → 57 leaves and expand the subcommand list to match the
  current chat tree (group-mute / group-mute-member / mute / set-top /
  list-categories / list-conversations, plus message reply, search /
  search-advanced, forward, emoji & text-emotion reactions, cards,
  group member-role CRUD, transfer-owner, set-admin, quit, ...). Count
  matches `dws chat --help` enumeration on this main.
2026-05-19 17:29:35 +08:00
修雨 6be124777f feat: align CLI to wukong IM envelope + schema pipeline / transform / market enhancements (#317)
把 test/pre-mcp-discovery 上累积的稳定改动整理成一个 commit 合入 main,剔除
test-only 的预发环境硬编码与 merge/revert 噪音。

- internal/helpers: 重写 chat.go 对齐 wukong IM envelope,拆分共享的 chat
  命令,删减历史测试桩
- internal/compat: 新增 Pipeline tool override + executor、CLIAliases
  override、json_parse_strict 与 file_read transform;envelope leaf cmd
  Args 从 NoArgs 放宽到 ArbitraryArgs
- internal/cli: stdin 处理增强,覆盖更多 --content / --content-file 路径
- internal/app: direct_runtime / runner 适配嫁接硬编码 helper 到动态命令树
- internal/market: registry 强化 + 单元测试

最终 diff: 12 个文件,+369 / -387。
2026-05-19 15:25:39 +08:00
修雨 355a1460d9 docs(changelog): add 1.0.29 release notes (#309)
CHANGELOG: write up 1.0.29 — summary paragraph, then Added (3 new
envelope products: aiapp / live / aisearch with their flag aliases /
subcommand aliases / short flags rationale), Fixed (#306 leaf cmd
ArbitraryArgs), and the existing Security entry (#300 app.json
edition partitioning) preserved.

README.md / README_zh.md: lift aiapp / aisearch / live out of "Coming
soon" into the Key Services table; rename "Coming soon" to keep only
conference; bump totals 16 → 19 products / 204 → 209 commands.
2026-05-17 17:59:28 +08:00
c6edc84e40 fix(auth): partition app.json filename by edition to isolate credentials (#300)
* fix(auth): partition app.json filename by edition to isolate credentials

Two dws binaries sharing the same config directory (typically ~/.dws or
DWS_CONFIG_DIR) previously read and wrote a single app.json. Editions
that pin AuthClientID via hooks still go through the open-core
post-login persistence path, which records a bare ClientID without a
paired ClientSecret. The sibling edition reading the same path would
then adopt that foreign clientID via ResolveAppCredentials.

Mirror the strategy already used by the cache loader
(pkg/config.EditionPartition): GetAppConfigPath returns a filename
suffixed with the active edition name. Open-source keeps "app.json" for
backwards compatibility; sibling editions land on "app-<edition>.json".
LoadAppConfig / SaveAppConfig / HasAppConfig / DeleteAppConfig all
route through GetAppConfigPath, so this single change physically
isolates credential files end-to-end without any read-time heuristics.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(auth): address app config partition review

* fix(auth): clean legacy sibling app config

* test(auth): cover legacy app config cleanup guards

* fix(auth): close app config review follow-ups

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 17:43:20 +08:00
修雨 f497047fff fix(compat): relax envelope leaf cmd Args from NoArgs to ArbitraryArgs (#306)
NewDirectCommand was hard-coding cobra.NoArgs for envelope-generated
leaf commands that have no positional bindings (totalMax == 0). This
is stricter than cobra's own default — legacyArgs (args.go:30-32)
returns nil for any command without subcommands.

The strict behavior surfaced as "unknown command \"<word>\" for
\"dws aisearch person\"" whenever an AI agent passed trailing
positional words after a leaf, e.g.

  dws aisearch person search --keyword "张"
  dws aisearch person user search --keyword "张"

Switching to cobra.ArbitraryArgs restores cobra's natural leaf
behavior: trailing positional args are silently ignored. Existing
positional-binding paths (MinimumNArgs / RangeArgs / MaximumNArgs)
are unchanged.

Verified against dws-wukong/auto-test/cli_to_mcp/testcases on
aiapp / live / aisearch: 50/50 pass (was 48/50; the 2 remaining
failures were the F-class extra-positional-args tolerance cases
this commit fixes).
2026-05-17 17:23:17 +08:00
修雨 a9de7d3ca4 chore(readme): refresh community DingTalk group QR (#296)
* chore(readme): refresh community DingTalk group QR

Replace the external alicdn-hosted QR image with a repo-tracked one
(.github/assets/community-qr.png), so the README is self-contained and
not dependent on third-party CDN availability.

QR encodes an external (cross-org) DingTalk group "dws开源沟通群",
valid until 2027-05-14. Scan with DingTalk mobile to join.

* chore(readme): use alicdn-hosted QR image (no repo binary)

Drop the repo-tracked .github/assets/community-qr.png and reference the
new community group QR via the alicdn CDN URL instead, matching the
original README convention (external image, no binary asset in tree).

QR points to "dws 开源沟通群" (external/cross-org DingTalk group),
valid until 2027-05-14. Mobile scan to join.

* chore(readme): match prior QR image width (150px)
2026-05-15 13:56:54 +08:00
FuShu-Yang 1c200d883f fix(app): prevent command field from overwriting existing endpoint (#297)
* fix(app): prevent command field from overwriting existing endpoint

In AppendDynamicServer, when a plugin declares command != id, the command
endpoint is written unconditionally, overwriting any previously registered entry.

Fix: use first-writer-wins - only write if the key is not yet present.

id registration and dynamicProducts remain unconditional (unaffected).

* test(app): add regression test for command endpoint first-writer-wins guard
2026-05-15 11:29:07 +08:00
修雨 d268524084 docs(changelog): add 1.0.28 release notes (#295) 2026-05-14 21:34:28 +08:00
修雨 ed4673e7d2 fix(chat): require --title for group messages, completing #250's symmetric fix (#294)
`send_message_as_user`'s schema also marks `title` as required, but
`buildChatMessageSendInvocation` only pre-validated it for direct
messages. Sending `dws chat message send --group <cid> --text "1"`
without `--title` therefore reached the API and returned the same
misleading `发群服务窗会话消息失败` business error that #250 fixed for
direct messages, just on the other branch.

Pre-validation now covers both branches: group sends without title
return `--title is required for group messages (--group)`, direct sends
keep the existing `--title is required for direct messages (...)`
message. `Long` help, the `--title` flag description, the first
`Example` line, and `skills/references/products/chat.md` (including the
deeper "drive → chat" workflow example) are realigned to
"群聊与单聊都必填". `internal/helpers/chat_test.go` gains a
`group-without-title` case, and the existing `group` / `positional-text`
success cases are updated to pass `--title`. No API request shape change.
2026-05-14 21:13:28 +08:00
修雨 de723914a5 docs(changelog): add 1.0.27 release notes (#293)
* docs(changelog): add 1.0.27 release notes

Covers what landed on main since 1.0.26:

- #291: file_read transform + CLIFlagOverride.MapsTo field, and the
  envelope-side rollout that turns it into `dws doc update --content`
  / `--content-file` (literal vs file/stdin → markdown).
- envelope: `dws sheet find --query` hidden alias via the existing
  CLIFlagOverride.Aliases — keeps wukong-doc copy-paste working on
  open-source. Needs `dws cache refresh` once.
- #285: suppress noisy WARN on stdio client shutdown.

* docs(changelog): translate 1.0.27 entry to English
2026-05-14 15:50:45 +08:00
FuShu-Yang 649801e479 fix(transport): suppress noisy WARN on stdio client shutdown (#285)
* fix(transport): suppress noisy WARN on stdio client shutdown

When Stop() kills the subprocess, cmd.Wait() always returns a non-zero
exit code which is expected behavior. Previously this propagated as an
error, causing "failed to stop stdio client ... exit status 1" warnings
on every normal CLI exit for stdio-based plugins.

Now Stop() returns nil when the process was explicitly killed, keeping
the error path only for cases where the process exits on its own with
a non-zero code (e.g. stdin close without kill).

🤖 Generated with [Qoder][https://qoder.com]

* fix(transport): address review feedback — simplify Stop(), fix test assertion

- Remove redundant `killed` flag; inline Kill+Wait+return nil
- Update integration test to positively assert Stop() returns nil after kill

🤖 Generated with [Qoder][https://qoder.com]
2026-05-14 14:45:23 +08:00
修雨 49c5bea4f3 feat(schema): file_read transform + CLIFlagOverride.MapsTo for --content/--content-file (#277 #278 #282 #288) (#291)
* feat(transform): add file_read transform for UTF-8 file / stdin content (#277)

Introduces a new ApplyTransform case "file_read":
- Input: a string flag value treated as a UTF-8 file path
- Special case: the path "-" reads from stdin
- Output: the file contents as a string
- Errors surface as validation errors (exit 2) — non-UTF-8, missing
  file, empty path, and non-string input all reject cleanly

This is a foundational primitive intended to compose with envelope
schema features so a CLI flag like --content-file can carry a path
that ultimately feeds a string-typed MCP parameter with the file's
contents.

## Scope note — MapsTo intentionally NOT included

The original proposal in #277 paired this transform with a new
CLIFlagOverride.MapsTo field that retargets a flag's value to a
different MCP parameter (e.g. --content / --content-file both feed
the upstream `markdown` param). Pre-production end-to-end validation
(see #282) showed that the MCP registry server-side schema does not
currently recognise `mapsTo` and strips it on serialisation. Every
other new envelope field (hidden / required / transform /
mutuallyExclusive / requireOneOf) survives — only mapsTo is dropped.

Shipping MapsTo without server-side support would land dead client
code. The MapsTo field + sourceFlag transform guard were therefore
removed from this PR; they will return in a follow-up PR once #282
(server-side schema acknowledgement of mapsTo) is resolved. The
file_read transform stays here because it composes with multiple
mechanisms beyond MapsTo and is independently testable.

## Tests

Six new cases in internal/compat/transform_test.go cover the
contract: literal file, stdin via "-", missing file, non-UTF-8
input, empty path, non-string input. All pass under both `go test`
and `go test -coverprofile`.

Refs #277, blocked-by #282

* feat(schema): add CLIFlagOverride.MapsTo for sibling-flag routing (#277 #282)

Adds the `MapsTo` field on `CLIFlagOverride` and wires the dispatch loop
in `compat.buildOverrideBindings` so a flag's final value (post-transform
or literal) is routed into a different MCP parameter slot than its own
property name. This lets two sibling CLI flags feed a single upstream
parameter — the canonical case being `--content` (literal) + `--content-file`
(transform: file_read) both mapping to `markdown`.

Tool-level `CLIToolOverride.MutuallyExclusive` (cobra MarkFlagsMutuallyExclusive)
is the right partner for guarding "set one, not both" at parse time; no
new exclusion machinery is added.

Closes the client-side gap previously misattributed to a server-side
mapsTo strip in #282. Once a doc envelope with mapsTo lands in pre-prod,
end-to-end `--content-file` becomes shippable, finishing #277 Step 1b.

Test coverage (internal/compat/dynamic_commands_test.go):
  - MapsTo without transform: literal --content → params[markdown]
  - MapsTo with file_read transform: --content-file path → params[markdown]
  - Sibling flags both mapsTo same target, only one set: clean routing
  - Sibling flags both set: rejected by tool-level MutuallyExclusive (regression)

Backward-compat: empty MapsTo preserves existing params[propertyName] write
semantics for every existing envelope.
2026-05-14 14:44:00 +08:00
6707e56f9c feat(cli): schema-aware sticky flag splitting and structured unknown-flag recovery (#272)
* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* 1.0.19 changelog

* stick opt

* fix(cli): utf-8 safe sticky suffix guard + changelog (#272)

SuffixLooksLikeValue used to read suffix[0] (a single byte) for both the
uuid format branch and the fallback "is the first rune a letter?" check.
For multi-byte UTF-8 leading runes — common in dws because value text is
often Chinese — this picked up only the first byte (0xE0..0xF4 lead),
which is not a letter and not a hex digit, so the function silently
returned true and let glued tokens like --name<CJK> get split into
--name <CJK>... This switches both branches to utf8.DecodeRuneInString
and adds a utf8.RuneError guard so invalid UTF-8 input is rejected too.

Also locks down the new behaviour in CHANGELOG ## [Unreleased]:
- Changed: schema-aware sticky flag splitting
- Added: available_flags field on unknown-flag errors

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 10:56:02 +08:00
修雨 2ba1dcdda4 docs(changelog): add 1.0.26 release notes (#271)
Cover the 5 PRs merged since v1.0.25:
- #259 -f ndjson / -f csv + real-traffic preferredListKeys extension
- #250 chat send --title required for direct messages (pre-existing
  Unreleased entry preserved verbatim)
- #242 Windows PAT URL truncation fix via rundll32 opener
- #268 axls preflight on dws doc download
- #267 DWS_DISABLE_KEYCHAIN fallback for macOS sandbox

Also document the resolution of #240 (dws doc comment *
PARAM_ERROR - 未找到指定工具): fix is in the market metadata
(`serverOverride: doc-comment` on the four comment toolOverrides)
rather than in CLI code, so existing users need to run
`dws cache refresh` once. Verified post-refresh that dry-run
resolves to the doc-comment MCP server endpoint and real calls
return normal business responses instead of the PARAM_ERROR.
2026-05-12 16:40:34 +08:00
修雨 1637ae16c7 fix(keychain): add DWS_DISABLE_KEYCHAIN fallback for macOS sandbox (#214) (#267)
In sandboxed macOS runtimes (e.g. Codex App), `security` / Keychain APIs
are blocked, so `keyring.Get`/`Set` for the DEK fails on every token
read/write. Add an opt-in env var that switches the macOS implementation
to the same file-based DEK scheme already used on Linux. Default
behavior is unchanged.

- Extract shared `fileDEK(service)` into `file_dek.go` (darwin || linux)
- Linux `getDEK` now delegates to `fileDEK`
- Darwin `getDEK` short-circuits to `fileDEK` when DWS_DISABLE_KEYCHAIN=1
- Document the tradeoff in reference.md (DEK and ciphertext co-located)
- Add darwin-only tests covering fallback path + overwrite
2026-05-12 14:57:00 +08:00
xuanandshangguanxuan.sgx eee19d7347 fix(pat): preserve auth link on Windows browser open (#242)
* fix(pat): preserve auth link on Windows browser open

* fix(pat): expose copy-safe authorization URL

* test(pat): preserve extra authorization route params

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-05-12 14:55:59 +08:00
xuanandshangguanxuan.sgx 19f7b59ffb fix(doc): preflight axls download (#268)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-05-12 11:48:31 +08:00
修雨 c4952d0207 feat(output): add -f ndjson and -f csv (#252) (#259)
* feat(output): add -f ndjson (implemented) and scaffold -f csv (#252)

`larksuite/cli` exposes --format ndjson / csv; dws only had json/table/raw/
pretty. This adds both as recognised global formats:

- ndjson: fully implemented (internal/output/ndjson.go) — top-level arrays
  and well-known list wrappers ({items|results|data|records|...}) emit one
  compact JSON document per line; anything else degrades to a single line.
  Streaming-friendly counterpart to `-f json`.
- csv: scaffolded (internal/output/csv.go) — writeCSV currently returns a
  clear "not implemented (#252)" error rather than silently degrading to
  JSON. A TODO block spells out the planned implementation (reuse the table
  renderer's extractRowsFromMap/rowsFromSlice flattening + encoding/csv).

Wired through Write() and normalizeFormat(); --format help string now lists
ndjson (csv to be added when writeCSV lands). Tests cover ndjson rendering
(array / wrapped-list / scalar) and pin the csv not-implemented contract.

Skeleton PR for #252 — ndjson is shippable as-is; csv is left for a
follow-up commit on this branch.

* feat(output): implement -f csv (#252)

Completes the CSV half of the format work. writeCSV mirrors the shape
decisions `-f table` already makes (reuses normalizePayload /
unwrapPrimaryObject / extractRowsFromMap / rowsFromSlice / formatValue) so
columns and value flattening are consistent between the two formats:

- a list of objects (bare or wrapped under items/results/data/records/...) →
  header row + one row per element; union of keys sorted; missing values are
  empty cells; nested objects/arrays render as compact JSON in the cell;
  sibling metadata of the list (total, hasMore, ...) is dropped.
- a single object → two-column key,value CSV (keys sorted).
- a non-uniform list / scalar → single-column `value` CSV.
- empty / nil → empty document.

encoding/csv.Writer handles RFC-4180 quoting (commas, quotes, newlines);
cells go through formatValue (also strips terminal control sequences, same
as the table renderer). `--fields` projection composes for free since
WriteFiltered applies SelectFields before Write.

--format help now lists csv; FormatCSV doc comment dropped the WIP marker;
the not-implemented test is replaced with real coverage (list with
comma/CJK/nested-array-as-JSON, wrapped-list-with-metadata, single object,
scalar) plus a --fields composition test.

* feat(output): broadcast list metadata as trailing columns in -f csv (#252)

Per review preference: instead of dropping the list's sibling metadata
(total, hasMore, ...) when rendering {records:[...], total:N} as CSV, append
each meta key as a trailing column repeated on every row, so a CSV consumer
never silently loses it (CSV has no "footer table" the way the table
renderer does). Meta keys colliding with a data column are skipped; an empty
list still emits the header (data + meta) plus one row of empty data cells
carrying the meta values. New broadcastMeta helper; doc comment + tests
updated (incl. an empty-list-with-metadata case).

* feat(output): recognise real DingTalk envelope keys in -f csv/ndjson/table

之前 -f csv 和 -f ndjson 的 list 检测白名单只认 items/results/data/list/
records/tools/servers/products,但真实钉钉响应用的是 result(单数直接数组
或一层包裹)/documents/emailAccounts/todoCards/events/messages,导致大
部分 list 命令的 csv 输出退化成 key,value 二列、ndjson 退化成整包一行。

修复点:

1. preferredListKeys 扩展加上真实 envelope key(result/documents/
   emailAccounts/todoCards/events/messages),并把它升级为 csv/table/
   ndjson 共享的"单一事实源"——filter.go 的 findDataList 不再维护自己
   的本地副本,直接复用这个列表。
2. extractRowsFromMap 改为委托 findDataList,自动获得"一层深度"的
   wrapper 支持({result: {todoCards: [...]}} 这种 envelope 现在能识
   别)。meta 合并:outer + inner 双层 sibling 拉通,outer 同名 key
   优先(避免 inner 把外层 success/total 等覆盖掉)。
3. writeTableish 和 writeCSV 调整 unwrapPrimaryObject 和
   extractRowsFromMap 的优先级——先试 list 检测,没命中再走 unwrap,
   避免 {result: {todoCards: [...]}} 被 unwrap 剥掉外层后直接走
   key,value 分支。
4. findDataList 允许"空数组+preferred key" 命中,保留原来"空 list +
   metadata 仍渲染为表格 + meta 广播一行"的行为。

新增 TestTabularDetectsRealDingTalkEnvelopes:四个真实 envelope 形态
(contact user search 的 result 直接数组、doc search 的 documents 顶层、
mail mailbox list 的 emailAccounts、todo task list 的 result.todoCards
一层深度)验证 ndjson 行数和 csv 表头都符合预期。

真接口回归(已登录态跑过):
- dws contact user search -f csv  → name/userId 列正常出表
- dws todo task list -f ndjson    → 20 行一条任务,可 jq -r .subject 直接管
- dws doc search -f csv           → 10 行 + nextPageToken 等 meta 广播尾列
- dws schema -f csv               → 无回归

Closes part of #252 follow-up.
2026-05-11 22:15:59 +08:00
修雨 ecf2684f58 docs(skills): add sheet product reference rewritten against dws schema (#266)
The `sheet` (在线电子表格) product registers **34 envelope tools** that
have been live for a while, but `skills/references/products/sheet.md`
was never added, and `skills/SKILL.md` 产品总览 didn't list `sheet`.
Agents had no per-command reference and would skip the product during
intent routing. This PR closes the gap with a doc **written against
the actual envelope state**, not copied from a downstream draft.

Process (different from prior #264, which was withdrawn for citing
phantom commands):

1. `dws schema | jq '.products[] | select(.id=="sheet")'` to enumerate
   the 34 real tools, with `required` and `flag_overlay` per tool.
2. Wrote sheet.md grouped by function: worksheet / range / dimension /
   merge / find-replace / filter-view (named views) / filter (sheet-
   level) / image / export. Each section lists tools with their
   canonical_path and cli_name as-they-actually-exist.
3. Documented v1.0.25 reality on naming: about a third of `sheet`
   tools still expose snake_case cli_names (`copy_sheet`,
   `submit_export_job`, `set_filter_criteria`, etc.) pending the
   `CLIAliases` (#246) rollout. Mixed style is called out at the top.
4. Documented the export reality: v1.0.25 envelope exposes only the
   atomic `submit_export_job` + `query_export_job`. There is **no
   consolidated `dws sheet export`** — Pipeline (#247) is the future
   plumbing for that. Doc walks through the two-step manual flow.
5. Verified before commit: every `dws sheet ...` reference in sheet.md
   maps to one of the 34 envelope cli paths. Zero phantom commands.
   Zero cross-repo `../url-patterns.md` style relative links.

Verification command:

  python3 verify.py  # set-diff sheet.md refs against `dws schema sheet`
  # → md covers 34/34 envelope cli paths; the only "extras" are
  #   `dws sheet export` and `dws sheet filter-view` mentioned
  #   purely as disambiguation/group prefix references.

Files:

- skills/references/products/sheet.md (new, 304 lines) — compact
  but complete: 命令命名风格说明 + 8 functional groups + common
  usage examples + 易混淆点 + 危险操作 + 何时不要用 sheet +
  权威参考命令.
- skills/SKILL.md — adds `sheet` row to 产品总览 table, adds an
  intent-routing line (在线电子表格/axls/工作表/单元格读写/合并
  单元格/筛选视图/导出 xlsx → `sheet`), extends frontmatter
  `description` to include 在线电子表格 (axls).
- CHANGELOG.md — extends v1.0.25 `### Added` with an entry that
  honestly describes both the 34 tools shipping and the v1.0.25
  caveats (mixed cli_name style + no consolidated export).
- README.md / README_zh.md — adds a Sheet row (34 cmds) to "Key
  Services" with full subcommand inventory; updates the total to
  "197 commands across 15 products" (was 163 / 14). `wiki` is
  intentionally untouched — it ships separately in PR #265.

Scope note: this PR intentionally does NOT bundle `wiki` —
PR #265 ships `wiki` independently because the prior combined
attempt #264 made review harder. Splitting keeps each PR
verifiable as a unit.
2026-05-11 16:54:07 +08:00
修雨 9e9b898dd2 docs(skills): add wiki product reference + register in SKILL.md/README (#265)
The `wiki` (知识库) product registers 7 envelope tools — `wiki.create_wikiSpace`,
`wiki.get_wikiSpace`, `wiki.list_wikiSpaces`, `wiki.search_wikiSpaces`, plus
`wiki.add_member` / `list_member` / `update_member` — surfacing as
`dws wiki space {create,get,list,search}` and `dws wiki member {add,list,update}`.
They've been registered for a while, but `skills/references/products/wiki.md`
was never added and `skills/SKILL.md` 产品总览 didn't list `wiki`, so agents
had no per-command reference to read and would skip it during intent routing.

Verified before commit: every `dws wiki ...` reference inside wiki.md
matches a `cli_name` from `dws schema` output (7/7).

Files:

- skills/references/products/wiki.md (new, 177 lines) — full command
  reference: space create / get / list / search + member add / list /
  update. Style matches existing `chat.md` / `aitable.md`. No cross-repo
  links (verified: 0 external relative refs).
- skills/SKILL.md — adds `wiki` row to 产品总览 table, adds an
  intent-routing line ("知识库 / wiki / 团队空间 / 知识库成员管理" → `wiki`),
  extends frontmatter `description` to include 知识库.
- CHANGELOG.md — v1.0.25 ### Added gains an entry explaining that the
  wiki envelope tools were already registered but the skill reference
  hadn't shipped; this release closes that doc gap.
- README.md / README_zh.md — adds a "Wiki" / "知识库" row to "Key Services"
  (7 cmds, subcommand groups `space` `member`), updates the total to
  "170 commands across 15 products" (was 163 / 14), removes `wiki` from
  the "Coming soon" callouts.

Scope note: this PR intentionally does NOT touch `sheet` — the prior
PR #264 was withdrawn after envelope verification showed several
sheet commands (`dws sheet export`, `media-upload`, `filter-view
set-criteria` / `clear-criteria`, `range get`) referenced in the
downstream draft don't actually exist in the v1.0.25 envelope. A
separate sheet PR will follow after a full rewrite against
`dws schema sheet`.
2026-05-11 16:21:53 +08:00
修雨andClaude Opus 4.7 17f692e7f1 fix(chat): require --title for direct messages, fix misleading help (#250)
* fix(chat): require --title for direct messages, fix misleading help

`dws chat message send --user <id> --text ...` (and the --open-dingtalk-id
variant) failed at the API layer with the cryptic "发群服务窗会话消息失败"
when --title was omitted, because send_direct_message_as_user requires a
title at the business layer. The CLI advertised the opposite: the --title
flag description said "可选" (optional) and one help example sent a direct
message without it.

- Validate --title up front for --user / --open-dingtalk-id sends:
  "--title is required for direct messages (--user / --open-dingtalk-id)".
  Group messages are unchanged (title stays optional there).
- Fix the long help, the --title flag description, the help examples, and
  skills/references/products/chat.md to say title is required for direct
  messages, optional for group messages.
- Tests: add --title to the direct-message routing cases that now require
  it, and add rejection cases for direct sends without --title.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(changelog): add Unreleased entry for #250 chat send --title fix

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:21:41 +08:00
62 changed files with 4814 additions and 634 deletions
+114 -2
View File
@@ -4,12 +4,124 @@ All notable changes to this project will be documented in this file.
The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and this project follows [Semantic Versioning](https://semver.org/).
## [1.0.25] - 2026-05-11
## [1.0.31] - 2026-05-21
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands.
Closes the last drive-surface gap with the Wukong edition: `dws drive upload` lands as a single-shot composite (`drive.get_upload_info` → HTTP PUT to OSS → `drive.commit_upload`) so a local file reaches DingTalk drive in one CLI invocation, no manual three-step orchestration. Two more drive commands — `dws drive list-spaces` (list visible drive spaces) and `dws drive delete` (delete a drive file, routed via `serverOverride` to the doc MCP server) — ship via the portal envelope; `dws cache refresh` once to pick them up. Companion skill docs teach the agent to recognise dingpan URLs of the form `alidocs.dingtalk.com/document/edit?dentryKey=…` / `…/document/preview?dentryKey=…` and pass the whole URL through to `--node` instead of trying to extract `dentryKey` by hand (the server interprets `dentryKey` and a bare `nodeId` differently — manual extraction was failing).
### Added
- **`dws drive upload --file <path> [--folder <dentryUuid>] [--space-id <id>] [--file-name <name>] [--mime-type <type>]`** (#335, see `internal/helpers/drive.go`) — composite leaf that runs the full three-step upload internally:
1. `drive.get_upload_info` — fetch the OSS-signed `resourceUrl` + `uploadId` + per-URL headers.
2. HTTP `PUT` the file binary to OSS (10-minute timeout, attaches every header returned by step 1).
3. `drive.commit_upload` — register the new file under the target space / folder.
`--dry-run` prints the three step invocations as a single JSON payload without making any network calls. `--file -` is rejected on purpose: this is a local-path upload, not stdin streaming. `--folder` only accepts a `dentryUuid`; pure-numeric values are rejected up front (`validateDriveParentID`) so callers don't accidentally pass a chat-link `dentryId` (a different ID namespace) where the drive API expects a `dentryUuid`. Response normalisation handles all the wrapper shapes the upstream returns — `content` / `result` envelopes, `resourceUrls[]` arrays, and the flat `resourceUrl` / `uploadUrl` fallbacks — so the composite produces a stable JSON shape regardless of which path the upstream takes. The helper only registers `upload`; the existing six envelope-generated leaves (`list` / `info` / `download` / `mkdir` / `upload-info` / `commit`) keep flowing through dynamic discovery unchanged. `pickCommands.MergeHardcodedLeaves` guarantees dynamic leaves win on collision, so this helper only fills the upload gap.
- **`dws drive list-spaces` and `dws drive delete` (envelope rollout)** (#335, ships via portal envelope) — `list_spaces` registers as a plain `cliName` alias on the existing drive MCP server; `delete_document` registers with `serverOverride: doc` so the call routes to the doc MCP server (which owns the delete API), surfacing under the drive command tree for ergonomics. **Existing users must run `dws cache refresh` once** to pick up these two new leaves; no binary upgrade is required for them, but they pair naturally with the v1.0.31 client that ships `upload`.
- **`skills/references/url-patterns.md`** (#335) — single authority for dispatching `alidocs.dingtalk.com` URLs across doc / sheet / wiki. Five-way split: `/i/p/<token>` short links → expand via `doc info`; `/i/nodes/<id>` node URLs → probe with `doc info` and route by `contentType` / `extension` / `nodeType`; `/spreadsheetv2/...` → `sheet`; `/document/edit|preview?dentryKey=<key>` (dingpan format) → pass the whole URL to `--node`, do not strip `dentryKey` by hand; `/i/share/...` (read-only share) → use the `read_url` fallback. The "URL precheck" Step 0 in `skills/SKILL.md` now redirects every URL-bearing prompt through this dispatcher before the agent picks a product.
### Changed
- **`skills/references/products/doc.md` — `--node` accepts dingpan URLs end-to-end** (#335) — `dws doc info` / `dws doc read` examples gain two extra rows showing `--node "https://alidocs.dingtalk.com/document/edit?dentryKey=<KEY>"` and `…/preview?dentryKey=<KEY>` as first-class `--node` inputs. The "URL recognition & DOC_ID extraction" table adds the `document/edit|preview?dentryKey=<key>` row, and the extraction rules are split into three explicit clauses so the agent stops manually pulling `dentryKey` out of the URL and feeding it as a bare `nodeId` (which the server rejects). The "nodeId dual-format note" upgrades to "nodeId multi-format note" with four equivalent `--node` input shapes side by side.
## [1.0.30] - 2026-05-19
Aligns the open-source CLI with the IM envelope and schema-pipeline plumbing the Wukong edition has been running in pre-prod, plus three user-visible quality-of-life fixes. The most visible one: chat-bot webhook payloads carrying literal Chinese mentions (`@所有人 周报来了` / `@张三 看一下`) no longer fail with `file not found` — `@` is only treated as the `@<filename>` file-injection prefix when followed by an ASCII path-shaped character. The `chat` command tree is refactored to lean on the service-discovery envelope: thin wrappers (`chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search`) move out of the hardcoded helper and become envelope-generated dynamic commands; the helper keeps only the chat commands with real business logic (intelligent routing, current-user resolution, response normalization, stdin/@file input). A new `dws chat message reply` joins the existing `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` family. Underneath: `transform: invert_bool` lets envelopes flip boolean semantics between CLI surface and MCP body (e.g. `--off` ↔ `mute=true`); the pipeline executor fail-fast on upstream `content.errorCode` instead of polling forever; service-discovery dedup keeps two envelope entries that share an MCP endpoint but declare different `cli.id` as separate descriptors (so the `bot-root` / `bot-message` / `bot-group` trio fronting one MCP server stays as three distinct CLI command roots); and `dws chat` no longer nests as `dws chat chat` when two envelope servers both declare the same top-level command name.
### Added
- **`transform: invert_bool` for envelope flag overrides** (#317, see `internal/compat/transform.go`) — flips a boolean at send time. Strings `true`/`1`/`yes`/`on` → `false`; `false`/`0`/`no`/`off`/`""` → `true`. Used when the CLI surface and the MCP body have opposite semantics — e.g. envelope declares `--off` on the CLI but the MCP parameter is `mute=true` for "muted". The framework flips at send time so the envelope keeps the natural CLI verb without forcing every caller to remember the inverted mapping. Coverage in `internal/compat/transform_test.go`.
- **`dws chat message reply`** (#317, see `internal/helpers/chat.go`) — reply to a chat message. Sits alongside `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` under `dws chat message`.
### Changed
- **`chat` command tree refactored to lean on the service-discovery envelope** (#317, commit `6be1247`) — `internal/helpers/chat.go` now only carries the chat commands that need real business logic on top of the raw MCP call: `chat message send` (current-user resolution + symmetric direct/group title validation), `chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` (bot routing + stdin/@file input), and `chat group create` (response normalization). The thin wrappers — `chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search` — are now produced by the envelope as dynamic commands. Net diff in the helper: `+358 / -71` overall (re-aligning to envelope-owned chat structure), and `chat_test.go` drops 71 lines of test-stubs the dynamic path covers natively. Every previously documented chat command keeps the same flag set and the same MCP tool routing — the surface is just sourced differently.
- **Pipeline executor fail-fast on `content.errorCode`** (#317, see `internal/compat/pipeline.go`) — when an upstream tool returns a non-empty `content.errorCode`, `executePipelineCall` raises a validation error immediately with the upstream `errorMessage` instead of proceeding into the poll/download phase. Pre-execution cobra validation (`MarkFlagRequired`) only checks that a flag was set, not that its value was non-empty — so a `--required-flag ""` reaches the upstream tool and the upstream rejects with `errorCode`. Without the short-circuit the pipeline kept polling for a task ID that would never exist, either spinning to `PollTimeout` or burning through retries with no actionable error. Exit code 2 (validation), same as any other CLI-layer pre-flight rejection.
- **Service-discovery dedup keys now include `cli.id`** (#317, see `internal/market/registry.go`) — `NormalizeServers` used to dedup envelope entries by endpoint alone (and by `displayName` in the second pass), which collapsed envelope entries that intentionally split one MCP endpoint into multiple CLI command trees. The `bot-root` / `bot-message` / `bot-group` trio all front the same `.../server/4717...` MCP endpoint and share the displayName `机器人消息`, but each declares a distinct `cli.id` and a distinct CLI command root; the old dedup kept only the last-write and dropped two of them. The dedup key now appends `#<cli.id>` when present, falling back to endpoint / name when absent so historical envelopes without `cli.id` keep their existing behaviour. Coverage in `internal/market/registry_test.go`.
### Fixed
- **`@<text>` injection no longer eats Chinese mentions like `@所有人` / `@张三`** (#317, see `internal/cli/stdin.go`) — `ReadFileArg` and `ResolveInputSource` used to treat *any* value starting with `@` as the `@<filename>` injection syntax. Chat-bot webhook payloads commonly contain literal mentions, so `dws chat message send-by-bot --text "@所有人 周报"` was failing with `file not found: 所有人 周报` before the message reached the API. The new `looksLikeFilePath` heuristic accepts `@` followed by an ASCII path-prefix character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin, and passes the value through unchanged otherwise. `@A 但接下来都是中文@测试` *does* still attempt a file lookup because the rune right after `@` is ASCII — this matches the documented `@<path>` prefix shape. The historical "bare `@` is an error" behaviour is preserved. Coverage in `internal/cli/stdin_test.go::TestReadFileArgChineseAtMention`.
- **`dws chat` no longer nests as `dws chat chat` when two envelope servers contribute the same top-level command** (#317, see `internal/compat/dynamic_commands.go`) — `BuildDynamicCommands` used to overwrite `topLevel[name]` on the second contribution and rely on `attachOrMerge` later, which then attached the *whole* incoming command (named `chat`) under the existing root, producing `dws chat chat <leaf>`. The new `mergeSubcommandsInto` moves the second contribution's *children* under the first root and drops the duplicate wrapper, so e.g. `group-chat` + `im` envelopes that both declare `cli.command: chat` produce a single flat `dws chat` subtree.
- **Multi-server tool-name authority correction in the runtime runner** (#317, see `internal/app/runner.go` + `internal/app/direct_runtime.go`) — when two envelope servers share the same `cli.command`, the per-product endpoint map `endpoints[cmd]` in `registerDynamicServer` is second-writer-wins, and `catalog.FindProduct` may return the wrong server's endpoint for a tool whose real owner is the *other* server. `runtimeRunner.Run` now cross-checks the canonical tool→endpoint map exposed by the new `directRuntimeToolEndpoint`: when the per-tool endpoint exists and differs from the per-product endpoint the catalog returned, the tool-owner endpoint wins. Pairs with the registry dedup change above so the routing matches the dedup result.
## [1.0.29] - 2026-05-17
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
### Added
- **`dws aiapp` / `dws live` / `dws aisearch` — three new products discovered via envelope** (no public issue; pre-Diamond rollout) — open-source `dws` now exposes:
- **`dws aiapp`** — AI application lifecycle: `create --prompt <p> [--attachments <json>] [--skills <csv>]` / `query --task-id <id>` / `modify --prompt <p> --thread-id <id> [--skills <csv>]`. Backed by upstream `create_ai_app` / `query_ai_app` / `modify_ai_app` MCP tools.
- **`dws live stream list`** — list my DingTalk live streams. Backed by upstream `get_my_lives`.
- **`dws aisearch person`** — enterprise people search by keyword + multi-dimension filter. Dimensions: `all` (default) / `name` / `department` / `position` / `duty` / `supervisor` / `subordinate` / `phone` / `jobNumber` — multiple comma-separated (`--dimension name,department`). Backed by upstream `enterprise_person_search`.
- The `aisearch` envelope additionally registers `-w` / `-d` short flags (keyword / dimension); hidden flag aliases `--query` / `--name` / `--q` / `--text` / `--find` all routing to `keyword`; and cobra subcommand aliases `search` / `find` / `query` / `user` / `people` / `search-person` / `search-user` / `user-search` / `lookup` / `ask` / `contact` all routing to `person`. This closes the F-class model-tolerance regression cases in `dws-wukong/auto-test/cli_to_mcp/testcases/aisearch/test_90_aisearch_param_regression.py` (50/50 pass for aiapp + live + aisearch on the pre-mcp build).
- **Users must run `dws cache refresh` once** to pick up the new envelopes; no binary upgrade is required, but pairs naturally with the v1.0.29 client (see Fixed below for the envelope-leaf-Args change).
### Fixed
- **Envelope-generated leaf commands now tolerate trailing positional args** (#306, no public issue) — `NewDirectCommand` in `internal/compat/registry.go` was hard-coding `cobra.NoArgs` for leaves without positional bindings (`totalMax == 0`). This is stricter than cobra's own `legacyArgs` (cobra `args.go:30-32` returns `nil` for any command without subcommands), and surfaced as `unknown command "<word>" for "<leaf>"` whenever an AI agent passed trailing positional words after a leaf — e.g. `dws aisearch person search --keyword "张"` or `dws aisearch person user search --keyword "张"`. Switching the `totalMax == 0` branch (and the initial value) from `cobra.NoArgs` to `cobra.ArbitraryArgs` restores cobra's natural leaf behavior: trailing positional args are silently ignored. Existing positional-binding paths (`MinimumNArgs` / `RangeArgs` / `MaximumNArgs`) are unchanged. Verified against `dws-wukong/auto-test/cli_to_mcp/testcases` — aiapp (9/9) + live (3/3) + aisearch (38/38) = **50/50** pass, vs 48/50 before this patch.
### Security
- **App credential files are partitioned by edition to prevent cross-edition credential leakage** (#300, no public issue; found during internal review) — different `dws` editions sharing the same config directory previously read and wrote the same `app.json`. A sibling edition that pinned its OAuth client ID could persist that ID through the shared post-login path, and the open-source build could later adopt it from the same file. Open-source/empty edition keeps the legacy `app.json` path for compatibility; sibling editions now use `app-<edition>.json`, matching the existing cache partitioning strategy. This prevents new cross-edition app credential writes and reads from colliding. After a sibling edition saves its new partitioned file, it also best-effort removes a legacy `~/.dws/app.json` only when that file's `clientId` matches the sibling edition being saved; a different, unparsable, or otherwise unowned `app.json` is left untouched to avoid deleting open-source credentials. If you previously ran multiple editions in one shared `~/.dws`, remove any confirmed-stale orphan manually with `rm ~/.dws/app.json` after verifying it is not the open-source credential file you still need.
## [1.0.28] - 2026-05-14
A single symmetric follow-up to 1.0.26's #250: `dws chat message send --group <cid>` now refuses an empty `--title` at the CLI layer instead of letting the call fall through to the API and surface a misleading `发群服务窗会话消息失败` error. No other behaviour changes.
### Fixed
- **`dws chat message send` rejects missing `--title` on group messages** (#294, completes #250) — `send_message_as_user`'s schema marks `title` as required (just like `send_direct_message_as_user`), but `buildChatMessageSendInvocation` only had the pre-validation on the direct-message branches. Group sends without a title were falling through to the API and returning the same misleading `发群服务窗会话消息失败` that #250 already fixed for direct messages. The check now covers both branches: missing `--title` on `--group` returns `--title is required for group messages (--group)` with exit code 2; missing on `--user` / `--open-dingtalk-id` keeps the original `--title is required for direct messages (--user / --open-dingtalk-id)`. The `Long` help, `--title` flag description, the first `Example`, and `skills/references/products/chat.md` (including the drive→chat workflow example) are realigned to "title is required for both direct and group messages" — the docs previously contradicted themselves (the prose said 群聊可选 while the flag listing said 必填). `internal/helpers/chat_test.go` adds a `group-without-title` rejection case; the existing `group` / `positional-text` success cases now pass `--title` to stay aligned with the new validation. No API request shape change — the server has always required `title`; the CLI now matches.
## [1.0.27] - 2026-05-14
Two user-visible fixes plus the schema primitive they're built on. `dws doc update` now reads Markdown from a file or stdin, so long / multi-line / table-heavy content no longer gets mangled by shell escaping; `dws sheet find --query` stops returning `unknown flag` on the open-source build, restoring copy-paste from internal wukong docs. Underneath, schema/discovery envelopes get a generic `file_read` transform and a `CLIFlagOverride.MapsTo` field that lets two sibling CLI flags route into the same MCP parameter slot. Also suppresses a noisy WARN on normal stdio-plugin shutdown.
### Added
- **`file_read` transform + `CLIFlagOverride.MapsTo` field** (#291, closes #277 #278 #282 #288) — discovery envelopes can now declare a path-typed CLI flag that performs the "file path → file contents string" conversion client-side before the value reaches the upstream MCP parameter.
- `transform: "file_read"` (`internal/compat/transform.go`) — reads the file at the flag's value with UTF-8 validation; `-` means stdin. Any IO / encoding failure is surfaced as a validation error (exit 2), distinct from the generic transient-failure path (exit 1).
- `CLIFlagOverride.MapsTo` (`internal/market/registry.go`) — redirects the flag's final value (post-transform or literal) into a named MCP parameter slot instead of the default `params[propertyName]`. This lets a single MCP parameter (e.g. `markdown`) be fed by two sibling CLI flags — a literal `--content` and a file-reading `--content-file` — paired with the existing tool-level `MutuallyExclusive` / `RequireOneOf` to express "exclusive, at least one".
- Wired into the `internal/compat/dynamic_commands.go` normalizer via a separate `mapsToRoutes` collection + routing pass; empty `MapsTo` preserves the legacy `params[propertyName] = value` semantics, so every pre-existing dynamic_commands test passes unchanged. Pre-prod end-to-end verified across 6 cases (see PR #291's Validation table).
- **`dws doc update --content-file <path>` (envelope rollout)** — fixes "long Markdown can't reach the doc". The old command only accepted `--content "..."`, so long / multi-line / table-heavy Markdown got mangled by shell escaping and AI agents writing >2KB of content were stuck. The envelope now maps both `--content` (literal) and `--content-file` (`file_read` transform) to the `markdown` parameter, makes them mutually exclusive via cobra's `MarkFlagsMutuallyExclusive`, and requires at least one via `RequireOneOf`. `--content-file -` reads from stdin, so `cat long.md | dws doc update --content-file -` works directly. **Existing users must run `dws cache refresh` once** to pick up the new envelope.
- **`dws sheet find --query` hidden alias (envelope rollout)** — fixes "unknown flag when copy-pasting commands across editions". Users copying `dws sheet find --query "..."` from internal wukong docs onto open-source `dws` got `unknown flag: --query`, because the open-source primary flag is named `--find`. The envelope now registers `--query` as a hidden alias of `--find` via `CLIFlagOverride.Aliases` (the field shipped in 1.0.26) — it doesn't show up in `--help`, but accepts values and writes to the same MCP parameter. `--find` behaviour is unchanged. Also requires `dws cache refresh` once.
### Fixed
- **Noisy `failed to stop stdio client: exit status 1` WARN on normal stdio-plugin shutdown** (#285) — when `Stop()` explicitly `Kill`s the subprocess, the non-zero exit code returned by `cmd.Wait()` is expected behaviour, but it was being propagated as an error and logged to stderr on every CLI exit, polluting agent log parsing. `Stop()` now returns `nil` after Kill + Wait; the error path is reserved for "process exited on its own with non-zero" (e.g. stdin close without an explicit Kill). `internal/transport/stdio.go` + `stdio_integration_test.go` assert "Stop() returns nil after kill".
## [1.0.26] - 2026-05-12
Platform-stability round: Windows PAT-auth browser opener no longer truncates URLs at `&userCode=`, macOS sandbox hosts get an opt-in keychain fallback, and `dws doc download` rejects `axls` nodes before requesting `drive:download` consent. Two new global output formats `-f ndjson` and `-f csv` (matching `larksuite/cli`) land as first-class citizens with real-traffic-verified list detection. The `dws doc comment *` regression tracked in #240 is also resolved — fix is in the market metadata, users just need `dws cache refresh` once.
### Added
- **`-f ndjson` and `-f csv` global output formats** (#259, closes #252) — `ndjson` emits one compact JSON record per line (works straight with `jq -c` / `while read` / log pipelines); `csv` goes through `encoding/csv` (RFC-4180 — quoting, embedded newlines, CJK all handled by stdlib) and reuses the existing `-f table` column resolver (`normalizePayload` / `unwrapPrimaryObject` / `extractRowsFromMap` / `rowsFromSlice` / `formatValue`) so table and csv stay visually aligned. After a 7-product real-traffic sweep (contact / chat / doc / mail / todo / minutes / schema), the `preferredListKeys` whitelist was extended to cover the actual DingTalk envelope shapes — `contact user search` (`result`), `chat search` (`result.value`), `doc search` (`documents`), `mail mailbox list` (`emailAccounts`), `todo task list` (`result.todoCards`) — so these commands now degrade into a proper row stream instead of collapsing to a single-line `key,value` blob. Lives in `internal/output/ndjson.go` + `internal/output/csv.go`; `--format` help in `internal/app/flags.go` now lists `ndjson|csv` alongside `json|table|raw|pretty`.
### Changed
- **Sticky flag splitting is now schema-aware** (#272) — PreParse `StickyHandler` 此前会把任何前缀命中已知 flag 的 `--flagsuffix` 一律切成 `--flag suffix`,于是 `--starttime20260507` 这类拼错被静默改写成 `--start time20260507`,把假值传到下游。新行为按 flag 的 pflag 类型 / JSON Schema `format` / `enum` 校验 suffix 是否像合法 value(共享逻辑见 `pkg/cmdutil/sticky_suffix.go`),不像就保留原 token 让 cobra 报 `unknown flag`。slice/array/object 类型的 flag 永不切分。首 rune 读取使用 `utf8.DecodeRuneInString`,对中文等多字节 value 安全。
### Added
- **`available_flags` field on unknown-flag errors** (#272) — `dws -f json` 的 unknown-flag 错误体里新增 `available_flags`(已排序、过滤掉 hidden 与内部 `json` / `params`),方便 agent 不解析 `--help` 就能恢复。Human-readable 输出会附 `Flags: ...` 行,截断在 200 字节内。
### Fixed
- **`dws chat message send` 单聊缺 `--title` 时前置校验** (#250) — 单聊(`--user` / `--open-dingtalk-id`)的底层工具 `send_direct_message_as_user` 在 API 层强制要求 title,缺失时返回误导性的 `发群服务窗会话消息失败`。CLI 现在在 `buildChatMessageSendInvocation` 里前置校验,直接返回 `--title is required for direct messages (--user / --open-dingtalk-id)`;同时把 `Long` help、`--title` flag 描述、Example 和 `skills/references/products/chat.md` 全部对齐为「单聊必填,群聊可选」。群聊行为不变。
- **PAT auth URLs were truncated on Windows browser open** (#242, fixes #230) — `cmd /c start <url>` on Windows interprets `&` as a command separator, so PAT URLs containing `&userCode=...` were silently chopped before the userCode segment, and the browser landed on a 0-permission DingTalk page. The retry opener now uses `rundll32 url.dll,FileProtocolHandler`, which passes the URL through verbatim. The PAT response also exposes a copy-safe `data.authorizationUrl` (in addition to the service-provided `data.uri`, which is preserved as-is), and human-readable PAT output prints `PAT_AUTHORIZATION_URL=<full-url>` on its own line so OpenClaw-style host wrappers that swallow or reformat stderr can still capture the full link. Legacy DingTalk hash-route shapes (`https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId=...%26userCode=...`) are normalised back into the working `/fe/old?hash=...#/personalAuthorization?...&userCode=...` form. Regression tests cover the issue-shaped URLs (encoded hash, fragment, `&userCode`) plus the OpenClaw malformed-hash variant.
- **`dws doc download` triggered `drive:download` PAT consent for unsupported axls nodes** (#268, fixes #190) — added a `get_document_info` preflight before `download_file`, so online-sheet (`axls`) nodes are rejected locally with guidance to use sheet range tools instead. The preflight reads `extension` from deterministic response paths (no recursive payload scan) and routes its own PAT errors back through `handlePatAuthCheck`, preserving device-flow / host-owned PAT behaviour. Costs one extra MCP roundtrip per `doc download` — deliberate, so the unsupported path fails before consent. Lives in `internal/app/doc_download_preflight.go`; coverage in `internal/app/runner_test.go`.
- **macOS sandbox hosts (Codex App etc.) couldn't read/write tokens via Keychain** (#267, fixes #214) — sandboxed macOS environments intercept `security` / Keychain APIs, so every token operation failed. New opt-in `DWS_DISABLE_KEYCHAIN=1` switches macOS to the same file-DEK path Linux uses (DEK at `~/Library/Application Support/dws-cli/dek`, mode `0600`), bypassing the system Keychain. Default behaviour is unchanged — fallback is strictly opt-in because file-DEK is a weaker trust model than Keychain-managed storage (DEK file sits next to ciphertext in the same directory). The Darwin / Linux file-DEK implementation is now shared in `internal/keychain/file_dek.go` (Linux path deduplicated by ~40 lines). Documented in `docs/reference.md` (中英) with the security tradeoff spelt out so users make the choice explicitly.
- **`dws doc comment {list,create,create-inline,reply}` returned `PARAM_ERROR - 未找到指定工具`** (fixes #240, also #234) — the four comment tools used to live on an independent `doc-comment` MCP server. After the Portal merged comment functionality into the `doc` server descriptor, the runtime `tools/list` on the merged `doc` server didn't include them, so every `dws doc comment *` call returned the "tool not found" PARAM_ERROR. The market metadata for the `doc` server now declares `serverOverride: "doc-comment"` on all four comment `toolOverrides`, so the existing CLI routing path sends `dws doc comment *` to the still-running `doc-comment` MCP server (which has the tools). No CLI code change was required, but **existing users must run `dws cache refresh` once** to pick up the updated descriptor — without that, the stale local market cache keeps pointing the call at the merged `doc` server and the error persists. Verified post-refresh: dry-run resolves to `https://mcp-gw.dingtalk.com/server/doc-comment` with tool `list_comments`, real calls return normal business responses (e.g. legitimate cross-org authz errors) instead of `未找到指定工具`.
## [1.0.25] - 2026-05-11
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands. Plus missing skill references for the already-registered `sheet` and `wiki` products are now shipped.
### Added
- **`sheet` (在线电子表格) skill reference + product-overview entry** — the `sheet` product registers **34 envelope tools** covering worksheet CRUD (`create` / `new` / `list` / `info` / `copy_sheet` / `update_sheet`), range read/write (`range read` / `range update` / `append`), dimension ops (`add-dimension` / `insert-dimension` / `delete-dimension` / `move-dimension` / `update-dimension`), merge (`merge-cells` / `unmerge-cells`), find/replace (`find` / `replace`), filter views (`filter-view {create, list, update, delete, update-criteria, delete-criteria}`), sheet-level filters (`create_filter` / `get_filter` / `update_filter` / `delete_filter` / `set_filter_criteria` / `clear_filter_criteria` / `sort_filter`), image write (`write-image`), and async export (`submit_export_job` + `query_export_job`). These were live in the envelope but `skills/references/products/sheet.md` had not shipped and `skills/SKILL.md` 产品总览 didn't list `sheet`, so agents had no reference to consult and were skipping it during intent routing. This release adds the doc, registers `sheet` in 产品总览 + 意图判断决策树, extends `description` to include 在线电子表格, adds a Sheet row to `README.md` / `README_zh.md` "Key Services", and notes the v1.0.25 reality on naming (about a third of `sheet` tools still expose snake_case cli_names pending `CLIAliases` (#246) rollout) and on export (no consolidated `dws sheet export` exists in v1.0.25 — `submit_export_job` + `query_export_job` are the atomic primitives; Pipeline (#247) provides the future plumbing).
- **`wiki` (知识库) skill reference + product-overview entry** — the wiki product's 7 envelope tools (`wiki.create_wikiSpace`, `wiki.get_wikiSpace`, `wiki.list_wikiSpaces`, `wiki.search_wikiSpaces`, `wiki.add_member`, `wiki.list_member`, `wiki.update_member`, surfaced as `dws wiki space create / get / list / search` and `dws wiki member add / list / update`) have been registered for a while, but no `skills/references/products/wiki.md` shipped with them, so agents had no per-command reference to consult. This release adds the reference doc, registers `wiki` in `skills/SKILL.md`'s 产品总览 table and 意图判断决策树, mentions 知识库 in the skill `description` frontmatter, adds a Wiki row to `README.md` / `README_zh.md` "Key Services", and removes `wiki` from the "Coming soon" callout (which was now stale).
- **`CLIToolOverride.CLIAliases` envelope field** (#246) — lets a single MCP tool register additional cobra command aliases via envelope JSON (e.g. `range read` also accepts `range get`, `member list` accepts `member ls`). Plumbed through the existing `Route.Aliases → cobra.Command.Aliases` path; sibling conflicts are silently dropped by cobra. Lives in `internal/market/registry.go` + `internal/compat/dynamic_commands.go`.
- **`json_parse_strict` transform** (#246) — strict-JSON variant of `json_parse` that does **not** fall back to YAML. Use when the upstream tool requires a structured array/object and silently coercing a malformed input to a scalar string would mask a real user error (observed: `filter-view --criteria 'NOT_VALID_JSON'` was being accepted and quietly creating an empty-criteria view). In `internal/compat/transform.go`.
- **`CLIToolOverride.Pipeline` + pipeline executor** (#247) — a single CLI command can now orchestrate an ordered sequence of MCP tool calls plus optional HTTP-download sinks, declared entirely in envelope JSON. Motivating use case: the "submit-job → poll-status → download-result" pattern (e.g. sheet export) that previously required per-product hardcoded helpers.
+12 -5
View File
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
<details>
<summary><strong>Table of Contents</strong></summary>
@@ -394,6 +394,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
--title "Weekly Report" --text @-
```
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
</details>
## Key Services
@@ -401,7 +403,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
| Chat / IM | `chat` (alias `im`) | 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | Messages (send / list / list-all / by-sender / mentions / focused / unread / topic replies / search), group CRUD + member management (incl. `add-bot`), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common groups lookup |
| Chat / IM | `chat` (alias `im`) | 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records |
@@ -410,20 +412,25 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
| AI Tables | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments; templates |
| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload |
| Drive | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | DingTalk drive file ops: list spaces, list / info / download, create folders, one-shot `upload` (three-step composite) or two-phase `upload-info` + `commit`, delete |
| Minutes | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | List AI meeting notes (mine / shared), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker replace, hot-word, upload session |
| Mail | `mail` | 4 | `mailbox` `message` | List mailbox addresses, KQL message search, get full message content, send email |
| Sheet | `sheet` | 34 | `range` `filter-view` (top-level: `create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read/write/append, dimension ops, cell merge, find/replace, named filter views + sheet-level filters, image write, async export (`submit_export_job` + `query_export_job` — no consolidated `export` in v1.0.25) |
| Wiki | `wiki` | 7 | `space` `member` | Knowledge base management: space `create` / `get` / `list` / `search` + member `add` / `list` / `update` |
| DevDoc | `devdoc` | 1 | `article` | Search the DingTalk Open Platform documentation |
| AI Search | `aisearch` | 1 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
| AI App | `aiapp` | 3 | — | AI application lifecycle: `create` (with prompt / attachments / skills) / `query` (by task ID) / `modify` (by thread ID) |
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
> **163 commands across 14 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **212 commands across 19 products.** Full listing with descriptions and usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
<details>
<summary>Coming soon</summary>
`conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
`conference` (video meetings)
</details>
+12 -5
View File
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
<details>
<summary><strong>目录</strong></summary>
@@ -394,6 +394,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
--title "周报" --text @-
```
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
</details>
## 核心服务
@@ -401,7 +403,7 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat`(别名 `im`)| 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | 消息(发送 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索)、群增删改 + 成员管理(含 `add-bot`)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊 |
| 群聊 | `chat`(别名 `im`)| 57 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 |
@@ -410,20 +412,25 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、详情、统计 |
| AI 表格 | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件;模板 |
| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 |
| 钉盘 | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | 钉盘文件操作:列出空间、文件列表 / 详情 / 下载、创建文件夹、一键 `upload`(三步合成)或两阶段 `upload-info` + `commit`、删除 |
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
| 邮箱 | `mail` | 4 | `mailbox` `message` | 邮箱地址列表、KQL 邮件搜索、邮件详情、发送邮件 |
| 在线电子表格 | `sheet` | 34 | `range` `filter-view`(顶层:`create` `new` `list` `info` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image` `copy_sheet` `update_sheet` `submit_export_job` `query_export_job` `create_filter` `get_filter` `update_filter` `delete_filter` `set_filter_criteria` `clear_filter_criteria` `sort_filter`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并、查找替换、命名筛选视图 + 表级筛选、写入图片、异步导出(`submit_export_job` + `query_export_job`,v1.0.25 暂无合并的 `export` 命令) |
| 知识库 | `wiki` | 7 | `space` `member` | 知识库管理:空间 `create` / `get` / `list` / `search` + 成员 `add` / `list` / `update` |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
| AI 搜问 | `aisearch` | 1 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
| AI 应用 | `aiapp` | 3 | — | AI 应用生命周期:`create`(含 prompt / attachments / skills)/ `query`(按任务 ID)/ `modify`(按 thread ID) |
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
> **14 个产品,163 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **19 个产品,212 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **关于 `chat bot`**:机器人能力(`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot 搜索)已合并到对应的 `chat` 子树下(例如 `dws chat message send-by-bot`、`dws chat group members add-bot`),保持 agent 视角下的命令面扁平易发现。不再有独立的顶层 `bot` 产品。
<details>
<summary>即将推出</summary>
`conference`(视频会议)· `aiapp`(AI 应用)· `live`(直播)· `wiki`(知识库)
`conference`(视频会议)
</details>
+1
View File
@@ -10,6 +10,7 @@
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
| `DWS_TRUSTED_DOMAINS` | Comma-separated trusted domains for bearer token (default: `*.dingtalk.com`). `*` for dev only / Bearer token 允许发送的域名白名单,默认 `*.dingtalk.com`,仅开发环境可设为 `*` |
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
## Exit Codes / 退出码
+22 -1
View File
@@ -210,6 +210,25 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
}
}
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
// whose toolOverrides registered this tool name. Used to correct catalog
// lookups when two envelope servers share the same cli.command and the
// per-product endpoint map collides (see runner.go cross-check).
func directRuntimeToolEndpoint(toolName string) (string, bool) {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
return "", false
}
dynamicMu.RLock()
te := dynamicToolEndpoints
dynamicMu.RUnlock()
if te == nil {
return "", false
}
endpoint, ok := te[toolName]
return endpoint, ok && strings.TrimSpace(endpoint) != ""
}
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
normalized := normalizeDirectRuntimeProductID(productID)
@@ -310,7 +329,9 @@ func AppendDynamicServer(server market.ServerDescriptor) {
}
cmd := strings.TrimSpace(server.CLI.Command)
if cmd != "" && cmd != id && endpoint != "" {
dynamicEndpoints[cmd] = endpoint
if _, exists := dynamicEndpoints[cmd]; !exists {
dynamicEndpoints[cmd] = endpoint
}
dynamicProducts[cmd] = true
}
for _, alias := range server.CLI.Aliases {
@@ -270,6 +270,68 @@ func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testi
}
}
// --- Command field first-writer-wins regression test ---
//
// When two plugins declare the same CLI.Command but different CLI.ID values,
// AppendDynamicServer must NOT let the second registration overwrite the
// command → endpoint mapping established by the first. The fix uses a simple
// "if not exists" guard on dynamicEndpoints[cmd].
const (
testFirstEndpoint = "https://mcp-gw.dingtalk.com/server/first-plugin-hash"
testSecondEndpoint = "https://mcp-gw.dingtalk.com/server/second-plugin-hash"
)
func firstPluginDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testFirstEndpoint,
CLI: market.CLIOverlay{
ID: "plugin-alpha",
Command: "shared-cmd",
},
}
}
func secondPluginDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testSecondEndpoint,
CLI: market.CLIOverlay{
ID: "plugin-beta",
Command: "shared-cmd",
},
}
}
// TestAppendDynamicServer_CommandEndpointFirstWriterWins verifies that when
// two plugins declare the same Command (but different IDs), only the first
// registration takes effect for the command → endpoint mapping. The second
// plugin's own id-based endpoint is unaffected.
func TestAppendDynamicServer_CommandEndpointFirstWriterWins(t *testing.T) {
withCleanDynamicRegistry(t)
AppendDynamicServer(firstPluginDescriptor())
AppendDynamicServer(secondPluginDescriptor())
// The command "shared-cmd" must resolve to the first plugin's endpoint.
assertEndpoint(t, "shared-cmd", "", testFirstEndpoint)
// Each plugin's own id-based endpoint is always unconditionally written.
assertEndpoint(t, "plugin-alpha", "", testFirstEndpoint)
assertEndpoint(t, "plugin-beta", "", testSecondEndpoint)
// Command must appear in dynamicProducts (discovery) regardless.
ids := DirectRuntimeProductIDs()
if !ids["shared-cmd"] {
t.Fatal("shared-cmd not found in DirectRuntimeProductIDs()")
}
if !ids["plugin-alpha"] {
t.Fatal("plugin-alpha not found in DirectRuntimeProductIDs()")
}
if !ids["plugin-beta"] {
t.Fatal("plugin-beta not found in DirectRuntimeProductIDs()")
}
}
// TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown verifies that
// tool-level routing still works as a fallback when productID is empty or has
// no registered endpoint (the original design intent for tool-level Priority 1).
+138
View File
@@ -0,0 +1,138 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const (
docProductID = "doc"
docDownloadFileTool = "download_file"
docGetDocumentInfoTool = "get_document_info"
docAXLSExtension = "axls"
)
func (r *runtimeRunner) preflightDocDownload(ctx context.Context, tc *transport.Client, endpoint string, invocation executor.Invocation) error {
if !isDocDownloadInvocation(invocation) {
return nil
}
nodeID := docDownloadNodeID(invocation.Params)
if nodeID == "" {
return nil
}
preflightStart := time.Now()
info, err := tc.CallTool(ctx, endpoint, docGetDocumentInfoTool, map[string]any{"nodeId": nodeID})
RecordTiming(ctx, "doc_download_preflight", time.Since(preflightStart))
if err != nil {
return err
}
if classify := edition.Get().ClassifyToolResult; classify != nil {
if err := classify(info.Content); err != nil {
return err
}
}
if patCheck := apperrors.ClassifyPatAuthCheck(info.Content); patCheck != nil {
return patCheck
}
if info.IsError {
return apperrors.NewAPI(
extractMCPErrorMessage(info),
apperrors.WithOperation("doc.get_document_info"),
apperrors.WithReason("doc_download_preflight_failed"),
apperrors.WithServerKey(docProductID),
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
apperrors.WithActions("dws doc info --node <nodeId>"),
)
}
if bizErr := detectBusinessError(info.Content); bizErr != "" {
return apperrors.NewAPI(
bizErr,
apperrors.WithOperation("doc.get_document_info"),
apperrors.WithReason("doc_download_preflight_failed"),
apperrors.WithServerKey(docProductID),
apperrors.WithHint("doc download 必须先确认节点类型,避免对不支持下载的在线表格触发 drive:download 授权。"),
apperrors.WithActions("dws doc info --node <nodeId>"),
)
}
if strings.EqualFold(documentInfoExtension(info.Content), docAXLSExtension) {
return unsupportedAXLSDownloadError()
}
return nil
}
func isDocDownloadInvocation(invocation executor.Invocation) bool {
return strings.EqualFold(strings.TrimSpace(invocation.CanonicalProduct), docProductID) &&
strings.TrimSpace(invocation.Tool) == docDownloadFileTool
}
func docDownloadNodeID(params map[string]any) string {
for _, key := range []string{"nodeId", "node", "dentryUuid"} {
if value, ok := params[key].(string); ok {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
}
return ""
}
func unsupportedAXLSDownloadError() error {
return apperrors.NewValidation(
"nodeId 指向的节点是钉钉表格(extension=axls),在线表格不支持直接下载。请使用 getRange 工具获取表格数据。",
apperrors.WithOperation("doc.download_file.preflight"),
apperrors.WithReason("unsupported_alidoc_extension"),
apperrors.WithServerKey(docProductID),
apperrors.WithHint("在线表格应先用 doc info 确认 extension,再改用表格 MCP 的 get_all_sheets / get_range 读取数据。"),
apperrors.WithActions("dws doc info --node <nodeId>", "使用表格 MCP get_all_sheets / get_range"),
)
}
func documentInfoExtension(content map[string]any) string {
for _, path := range [][]string{
{"result", "extension"},
{"data", "extension"},
{"extension"},
} {
if value := stringAtPath(content, path...); value != "" {
return value
}
}
return ""
}
func stringAtPath(value any, path ...string) string {
current := value
for _, key := range path {
object, ok := current.(map[string]any)
if !ok {
return ""
}
current = object[key]
}
if text, ok := current.(string); ok {
return strings.TrimSpace(text)
}
return ""
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
stderrors "errors"
"fmt"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
func TestFlagErrorWithSuggestions_authStructured(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "login", Run: func(*cobra.Command, []string) {}}
orig := fmt.Errorf("unknown flag: --json")
err := flagErrorWithSuggestions(cmd, orig)
var ae *apperrors.Error
if !stderrors.As(err, &ae) {
t.Fatalf("want *apperrors.Error, got %T", err)
}
if ae.Message != orig.Error() {
t.Fatalf("Message = %q, want %q", ae.Message, orig.Error())
}
if ae.Reason != "unknown_flag" {
t.Fatalf("Reason = %q, want unknown_flag", ae.Reason)
}
if ae.Hint == "" || !strings.Contains(ae.Hint, "format json") {
t.Fatalf("Hint = %q", ae.Hint)
}
if ae.Cause != orig {
t.Fatalf("Cause = %v, want orig", ae.Cause)
}
if !stderrors.Is(err, orig) {
t.Fatal("errors.Is(err, orig) should hold via unwrap")
}
}
func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
cmd.Flags().String("start", "", "begin time")
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
orig := fmt.Errorf("unknown flag: --starttime1")
err := flagErrorWithSuggestions(cmd, orig)
var ae *apperrors.Error
if !stderrors.As(err, &ae) {
t.Fatalf("want *apperrors.Error, got %T", err)
}
if ae.Reason != "unknown_flag" {
t.Fatalf("Reason = %q", ae.Reason)
}
if strings.Contains(ae.Hint, "Space required") {
t.Fatalf("false glue must not suggest space: %q", ae.Hint)
}
if !strings.Contains(ae.Hint, "help") {
t.Fatalf("expected help fallback in hint, got %q", ae.Hint)
}
if len(ae.AvailableFlags) != 1 || ae.AvailableFlags[0] != "start" {
t.Fatalf("AvailableFlags = %v, want [start]", ae.AvailableFlags)
}
}
+1 -1
View File
@@ -41,7 +41,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
cmd.PersistentFlags().BoolVar(&flags.Debug, "debug", false, "显示调试日志")
cmd.PersistentFlags().BoolVar(&flags.DryRun, "dry-run", false, "预览操作内容,不实际执行")
cmd.PersistentFlags().StringVar(&flags.Fields, "fields", "", "筛选输出字段 (逗号分隔, 如: name,id,status)")
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty")
cmd.PersistentFlags().StringVarP(&flags.Format, "format", "f", "json", "输出格式: json|table|raw|pretty|ndjson|csv")
cmd.PersistentFlags().StringVar(&flags.JQ, "jq", "", "jq 表达式过滤输出 (如: '.items[] | .name')")
cmd.PersistentFlags().BoolVar(&flags.Mock, "mock", false, "使用 Mock 数据 (开发调试用)")
cmd.PersistentFlags().StringVarP(&flags.Output, "output", "o", "", "Write command output to a file")
+25 -14
View File
@@ -224,6 +224,9 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
data = map[string]any{}
payload["data"] = data
}
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
data["authorizationUrl"] = apperrors.PATAuthorizationURL(rawURI)
}
data["openBrowser"] = openBrowser
encoded, err := json.Marshal(payload)
@@ -359,10 +362,10 @@ func openPATAuthorizationURI(rawURI string) error {
return nil
}
// The PAT service returns the complete authorization URL. Treat it as an
// opaque string and open it verbatim instead of parsing/rebuilding it
// locally, because required parameters may live in query, hash, or
// fragment sections.
return openBrowserFunc(rawURI)
// opaque string unless it is the known legacy DingTalk hash-route variant.
// That variant is normalized by the PAT error contract helper while still
// preserving the original data.uri in structured output.
return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI))
}
func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
@@ -457,7 +460,7 @@ func handlePatAuthCheck(
if wantsStructuredPATOutput(r) {
if openBrowser && patData.Data.URI != "" {
_ = openBrowserFunc(patData.Data.URI)
_ = openPATAuthorizationURI(patData.Data.URI)
}
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorWithOpenBrowser(patErr.RawJSON, openBrowser)}
}
@@ -475,9 +478,11 @@ func handlePatAuthCheck(
fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc)
}
if patData.Data.URI != "" {
fmt.Fprintf(output, " %s %s\n\n", dim("🔗"), cyan(patData.Data.URI))
authURL := apperrors.PATAuthorizationURL(patData.Data.URI)
fmt.Fprintf(output, " %s 授权链接: %s\n", dim("🔗"), cyan(authURL))
fmt.Fprintf(output, " PAT_AUTHORIZATION_URL=%s\n\n", authURL)
if openBrowser {
_ = openPATAuthorizationURI(patData.Data.URI)
_ = openPATAuthorizationURI(authURL)
}
}
@@ -718,18 +723,24 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
}
}
// tryOpenBrowser opens url in the default browser; errors are silently ignored.
func tryOpenBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
func browserOpenCommand(goos, rawURL string) *exec.Cmd {
switch goos {
case "darwin":
cmd = exec.Command("open", url)
return exec.Command("open", rawURL)
case "linux":
cmd = exec.Command("xdg-open", url)
return exec.Command("xdg-open", rawURL)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
return exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
default:
return nil
}
}
// tryOpenBrowser opens rawURL in the default browser; errors are silently ignored.
func tryOpenBrowser(rawURL string) error {
cmd := browserOpenCommand(runtime.GOOS, rawURL)
if cmd == nil {
return nil
}
return cmd.Start()
}
+101 -8
View File
@@ -624,7 +624,7 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
if !retryHasKey {
t.Fatal("expected retry context to have patRetryingKey")
}
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err != nil {
if _, err := os.Stat(authpkg.GetAppConfigPath(configDir)); err != nil {
t.Fatalf("expected approved PAT flow to persist app.json, stat error = %v", err)
}
// Verify SetClientIDFromMCP was called with the PAT response clientId.
@@ -700,7 +700,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in host mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
}
@@ -759,7 +759,7 @@ func TestHandlePatAuthCheck_HostControlledEmptyFlowID_StillReturnsContract(t *te
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in host mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
}
patOut, ok := err.(*apperrors.PATError)
@@ -855,7 +855,7 @@ func TestHandlePatAuthCheck_JSONModeReturnsStructuredPATErrorWithoutRetry(t *tes
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
}
@@ -903,7 +903,8 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
fallback: mock,
globalFlags: &GlobalFlags{Format: "json"},
}
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"https://example.com/pat","clientId":"test-client-id"}}`
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Df72437f040f04a8295988ff71e690b35%26userCode%3D98JV-JSBL#/personalAuthorization?flowId=f72437f040f04a8295988ff71e690b35&userCode=98JV-JSBL"
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"` + rawURI + `","clientId":"test-client-id"}}`
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
@@ -917,11 +918,29 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
}
if opened != "https://example.com/pat" {
t.Fatalf("opened url = %q, want https://example.com/pat", opened)
if opened != rawURI {
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
}
patOut, ok := err.(*apperrors.PATError)
if !ok {
t.Fatalf("expected *PATError, got %T: %v", err, err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(patOut.RawJSON), &payload); err != nil {
t.Fatalf("json.Unmarshal(json PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
}
data, _ := payload["data"].(map[string]any)
if got, _ := data["uri"].(string); got != rawURI {
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
}
if got, _ := data["authorizationUrl"].(string); got != rawURI {
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
}
if got, ok := data["openBrowser"].(bool); !ok || !got {
t.Fatalf("data.openBrowser = %#v, want true", data["openBrowser"])
}
}
@@ -1189,4 +1208,78 @@ func TestHandlePatAuthCheck_OpensOpaqueURIWithoutRebuild(t *testing.T) {
if opened != rawURI {
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
}
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+rawURI) {
t.Fatalf("output missing copy-safe PAT_AUTHORIZATION_URL line:\n%s", got)
}
}
func TestHandlePatAuthCheck_NormalizesLegacyHashRouteForBrowserAndOutput(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
defer server.Close()
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN"
wantURL := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN"
var opened string
origOpenBrowser := openBrowserFunc
openBrowserFunc = func(rawURL string) error {
opened = rawURL
return nil
}
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
var retryCalled bool
mock := &mockRunner{
runFunc: func(ctx context.Context, inv executor.Invocation) (executor.Result, error) {
retryCalled = true
return executor.Result{Response: map[string]any{"ok": true}}, nil
},
}
runner := &runtimeRunner{fallback: mock}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-legacy-hash", "test-client-id", rawURI)}
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
CanonicalProduct: "test",
Tool: "test_tool",
}, patErr, configDir, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !retryCalled {
t.Fatal("expected retry to run after approved PAT flow")
}
if opened != wantURL {
t.Fatalf("opened url = %q, want normalized %q", opened, wantURL)
}
if got := buf.String(); !strings.Contains(got, "PAT_AUTHORIZATION_URL="+wantURL) {
t.Fatalf("output missing normalized PAT_AUTHORIZATION_URL line:\n%s", got)
}
}
func TestBrowserOpenCommand_WindowsPreservesOpaquePATURI(t *testing.T) {
t.Parallel()
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Df72437f040f04a8295988ff71e690b35%26userCode%3D98JV-JSBL#/personalAuthorization?flowId=f72437f040f04a8295988ff71e690b35&userCode=98JV-JSBL"
cmd := browserOpenCommand("windows", rawURI)
if cmd == nil {
t.Fatal("browserOpenCommand(windows) returned nil")
}
if got := cmd.Args[0]; got == "cmd" {
t.Fatalf("windows browser opener must not route PAT URLs through cmd.exe: args=%v", cmd.Args)
}
if got := len(cmd.Args); got != 3 {
t.Fatalf("windows browser opener args length = %d, want 3: %v", got, cmd.Args)
}
if got := cmd.Args[0]; got != "rundll32" {
t.Fatalf("windows browser opener command = %q, want rundll32", got)
}
if got := cmd.Args[1]; got != "url.dll,FileProtocolHandler" {
t.Fatalf("windows browser opener handler = %q, want url.dll,FileProtocolHandler", got)
}
if got := cmd.Args[2]; got != rawURI {
t.Fatalf("windows browser opener URL arg = %q, want verbatim %q", got, rawURI)
}
}
+23 -1
View File
@@ -47,6 +47,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
@@ -133,7 +134,28 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
for flag, suggestion := range suggestions {
if strings.Contains(errMsg, "unknown flag: "+flag) {
return fmt.Errorf("%w\n%s", err, suggestion)
return apperrors.NewValidation(
errMsg,
apperrors.WithHint(suggestion),
apperrors.WithReason("unknown_flag"),
apperrors.WithCause(err),
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
}
if strings.Contains(errMsg, "unknown flag:") {
fix := cmdutil.SuggestFlagFix(cmd, err)
if fix.Suggestion != "" {
return apperrors.NewValidation(
errMsg,
apperrors.WithHint(fix.Suggestion),
apperrors.WithReason("unknown_flag"),
apperrors.WithCause(err),
apperrors.WithActions(fmt.Sprintf("Run '%s --help' for valid flags", cmd.CommandPath())),
apperrors.WithAvailableFlags(cmdutil.VisibleFlagNames(cmd)...),
)
}
}
+24
View File
@@ -219,6 +219,19 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
if override, ok := productEndpointOverride(invocation.CanonicalProduct); ok {
endpoint = override
}
// Multi-server tool-name authority correction.
//
// When two envelope servers share the same cli.command (e.g. group-chat
// and im both publish `dws chat ...`), the endpoints[cmd] map in
// registerDynamicServer is the second-writer wins, and catalog FindProduct
// may pick the wrong product's Endpoint for a tool whose real owner is
// a different server. Cross-check the canonical tool→endpoint map: when
// the per-tool endpoint exists and differs from the per-product endpoint
// catalog returned, trust the tool-owner endpoint (the server that
// actually declares this tool in its toolOverrides).
if toolEndpoint, ok := directRuntimeToolEndpoint(invocation.Tool); ok && toolEndpoint != "" && toolEndpoint != endpoint {
endpoint = toolEndpoint
}
return r.executeInvocation(ctx, endpoint, invocation)
}
@@ -364,6 +377,17 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
defer cancel()
}
if err := r.preflightDocDownload(callCtx, tc, endpoint, invocation); err != nil {
if patCheck := apperrors.AsPatAuthCheckError(err); patCheck != nil {
if IsPatRetrying(ctx) {
return executor.Result{}, patCheck
}
return handlePatAuthCheck(ctx, r, invocation, patCheck, defaultConfigDir(), os.Stderr)
}
captureRuntimeFailure(invocation, err, err)
return executor.Result{}, err
}
callStart := time.Now()
callResult, err := tc.CallTool(callCtx, endpoint, invocation.Tool, invocation.Params)
RecordTiming(ctx, "mcp_call", time.Since(callStart))
+214
View File
@@ -17,6 +17,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
@@ -27,7 +28,10 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
)
@@ -324,6 +328,186 @@ func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
}
}
func TestDocDownloadPreflightRejectsAXLSBeforeDownloadPAT(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := jsonRPCToolName(req)
calls = append(calls, name)
switch name {
case docGetDocumentInfoTool:
writeJSONRPCToolResult(t, w, req, map[string]any{
"success": true,
"result": map[string]any{
"contentType": "ALIDOC",
"extension": "axls",
"nodeType": "file",
},
}, false)
case docDownloadFileTool:
t.Fatalf("download_file should not be called for axls")
default:
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
}
}))
defer server.Close()
runner := runtimeRunnerForHTTPTest(server)
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: docProductID,
Tool: docDownloadFileTool,
CanonicalPath: "doc.download_file",
Params: map[string]any{"nodeId": "axls-node"},
})
if err == nil {
t.Fatal("executeInvocation() error = nil, want axls rejection")
}
if !strings.Contains(err.Error(), "extension=axls") {
t.Fatalf("executeInvocation() error = %v, want extension=axls guidance", err)
}
var typed *apperrors.Error
if !errors.As(err, &typed) {
t.Fatalf("executeInvocation() error = %T, want *errors.Error", err)
}
if typed.Category != apperrors.CategoryValidation {
t.Fatalf("error category = %q, want validation", typed.Category)
}
if typed.Reason != "unsupported_alidoc_extension" {
t.Fatalf("error reason = %q, want unsupported_alidoc_extension", typed.Reason)
}
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
t.Fatalf("tool calls = %q, want only %s", got, docGetDocumentInfoTool)
}
}
func TestDocDownloadPreflightAllowsNonAXLSDownload(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := jsonRPCToolName(req)
calls = append(calls, name)
switch name {
case docGetDocumentInfoTool:
writeJSONRPCToolResult(t, w, req, map[string]any{
"success": true,
"result": map[string]any{
"contentType": "DRIVE",
"extension": "xlsx",
"nodeType": "file",
},
}, false)
case docDownloadFileTool:
writeJSONRPCToolResult(t, w, req, map[string]any{
"resourceUrl": []any{"https://example.invalid/file.xlsx"},
}, false)
default:
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
}
}))
defer server.Close()
runner := runtimeRunnerForHTTPTest(server)
result, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: docProductID,
Tool: docDownloadFileTool,
CanonicalPath: "doc.download_file",
Params: map[string]any{"nodeId": "xlsx-node"},
})
if err != nil {
t.Fatalf("executeInvocation() error = %v", err)
}
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool+","+docDownloadFileTool {
t.Fatalf("tool calls = %q, want preflight then download", got)
}
content, ok := result.Response["content"].(map[string]any)
if !ok {
t.Fatalf("response.content = %#v, want map", result.Response["content"])
}
if _, ok := content["resourceUrl"]; !ok {
t.Fatalf("response.content.resourceUrl missing: %#v", content)
}
}
func TestDocDownloadPreflightPATAuthorizationUsesExistingHandler(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
originalOpenBrowser := openBrowserFunc
var openedURI string
openBrowserFunc = func(uri string) error {
openedURI = uri
return nil
}
t.Cleanup(func() { openBrowserFunc = originalOpenBrowser })
const authURI = "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-1%26userCode%3DCODE#/personalAuthorization?flowId=flow-1&userCode=CODE"
var calls []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := jsonRPCToolName(req)
calls = append(calls, name)
switch name {
case docGetDocumentInfoTool:
writeJSONRPCToolResult(t, w, req, map[string]any{
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
"data": map[string]any{
"flowId": "flow-1",
"uri": authURI,
"clientId": "client-1",
},
}, false)
case docDownloadFileTool:
t.Fatalf("download_file should not be called before preflight PAT authorization")
default:
http.Error(w, "unexpected tool "+name, http.StatusBadRequest)
}
}))
defer server.Close()
runner := runtimeRunnerForHTTPTest(server)
runner.globalFlags.Format = "json"
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
CanonicalProduct: docProductID,
Tool: docDownloadFileTool,
CanonicalPath: "doc.download_file",
Params: map[string]any{"nodeId": "pat-node"},
})
if err == nil {
t.Fatal("executeInvocation() error = nil, want PAT error")
}
var patErr *apperrors.PATError
if !errors.As(err, &patErr) {
t.Fatalf("executeInvocation() error = %T, want *errors.PATError", err)
}
if openedURI != authURI {
t.Fatalf("opened URI = %q, want %q", openedURI, authURI)
}
if got := strings.Join(calls, ","); got != docGetDocumentInfoTool {
t.Fatalf("tool calls = %q, want only %s before PAT authorization", got, docGetDocumentInfoTool)
}
}
// TestRuntimeRunnerRejectsUnauthenticatedRequest verifies that requests without
// a valid token are rejected with a clear error before making any network call.
func TestRuntimeRunnerRejectsUnauthenticatedRequest(t *testing.T) {
@@ -658,6 +842,36 @@ func contentScanServer() *mockmcp.Server {
return mockmcp.MustNewServer(fixture)
}
func runtimeRunnerForHTTPTest(server *httptest.Server) *runtimeRunner {
client := transport.NewClient(server.Client())
client.Stderr = &bytes.Buffer{}
return &runtimeRunner{
transport: client,
globalFlags: &GlobalFlags{Token: "test-token", Timeout: 30},
}
}
func jsonRPCToolName(req map[string]any) string {
params, _ := req["params"].(map[string]any)
if params == nil {
return ""
}
name, _ := params["name"].(string)
return name
}
func writeJSONRPCToolResult(t *testing.T, w http.ResponseWriter, req map[string]any, content map[string]any, isError bool) {
t.Helper()
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"content": content,
"isError": isError,
},
})
}
func TestClassifyToolResultHookPreemptsBusinessError(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
+49 -5
View File
@@ -16,21 +16,31 @@ package auth
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
configpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
const (
// appConfigFile is the filename for storing app credentials.
appConfigFile = "app.json"
// appConfigFile is the filename for the open-source edition's app
// credentials store. Sibling editions get a name-suffixed file via
// config.EditionFileName so two dws binaries sharing the same config
// directory (~/.dws or DWS_CONFIG_DIR) cannot read/write each other's
// credentials. See GetAppConfigPath for the path derivation contract.
appConfigBase = "app"
appConfigExt = ".json"
appConfigFile = appConfigBase + appConfigExt
)
// AppConfig represents the application credentials configuration.
// This is stored in ~/.dws/app.json with the client secret securely stored in keychain.
// This is stored in the edition-specific app config file, with the client
// secret securely stored in keychain when present.
type AppConfig struct {
ClientID string `json:"clientId"`
ClientSecret SecretInput `json:"clientSecret"`
@@ -53,9 +63,14 @@ var (
cachedResolvedMu sync.RWMutex
)
// GetAppConfigPath returns the path to the app config file.
// GetAppConfigPath returns the path to the app config file for the
// currently-active edition. The filename is partitioned by edition so that
// two dws binaries from different editions sharing the same configDir
// (typically ~/.dws or DWS_CONFIG_DIR) cannot read or overwrite each
// other's credentials. Open-source stays on "app.json" for backwards
// compatibility; sibling editions land on "app-<edition>.json".
func GetAppConfigPath(configDir string) string {
return filepath.Join(configDir, appConfigFile)
return filepath.Join(configDir, configpkg.EditionFileName(edition.Get().Name, appConfigBase, appConfigExt))
}
// LoadAppConfig loads the app configuration from disk.
@@ -105,6 +120,7 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
if err := helpers.AtomicWriteJSON(path, append(data, '\n')); err != nil {
return fmt.Errorf("writing app config: %w", err)
}
cleanupLegacySiblingAppConfig(configDir, config)
// Update cache
cachedAppConfigMu.Lock()
@@ -121,6 +137,34 @@ func SaveAppConfig(configDir string, config *AppConfig) error {
return nil
}
func cleanupLegacySiblingAppConfig(configDir string, config *AppConfig) {
if config == nil || config.ClientID == "" || configpkg.IsOpenEdition(edition.Get().Name) {
return
}
legacyPath := filepath.Join(configDir, appConfigFile)
if legacyPath == GetAppConfigPath(configDir) {
return
}
data, err := os.ReadFile(legacyPath)
if err != nil {
return
}
var legacy AppConfig
if err := json.Unmarshal(data, &legacy); err != nil {
return
}
if legacy.ClientID != config.ClientID {
return
}
if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) {
slog.Debug("auth: best-effort cleanup of legacy app config failed", "path", legacyPath, "error", err)
}
}
// DeleteAppConfig removes the app configuration and associated keychain secrets.
func DeleteAppConfig(configDir string) error {
// Load existing config to clean up keychain
+256
View File
@@ -0,0 +1,256 @@
// 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 (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// Verifies that two dws binaries from different editions sharing the same
// configDir (e.g. ~/.dws via DWS_CONFIG_DIR) read and write disjoint
// app.json files. Without partitioning, a sibling edition's post-login
// persistence path could leak its pinned ClientID into the open-source
// build by reading the shared file.
func TestGetAppConfigPath_OpenEditionUsesLegacyName(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
for _, name := range []string{"", "open"} {
edition.Override(&edition.Hooks{Name: name})
got := GetAppConfigPath("/tmp/cfg")
want := filepath.Join("/tmp/cfg", "app.json")
if got != want {
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", name, got, want)
}
}
}
func TestGetAppConfigPath_SiblingEditionUsesSuffixedName(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
cases := []struct {
editionName string
wantFile string
}{
{"wukong", "app-wukong.json"},
{"dev", "app-dev.json"},
{"embedded", "app-embedded.json"},
}
for _, tc := range cases {
edition.Override(&edition.Hooks{Name: tc.editionName})
got := GetAppConfigPath("/tmp/cfg")
want := filepath.Join("/tmp/cfg", tc.wantFile)
if got != want {
t.Fatalf("edition=%q: GetAppConfigPath = %q, want %q", tc.editionName, got, want)
}
}
}
func TestGetAppConfigPath_OpenAndSiblingAreDisjoint(t *testing.T) {
// End-to-end invariant: when the same configDir is observed from two
// different editions, the resulting app.json paths must NOT collide.
prev := edition.Get()
t.Cleanup(func() { edition.Override(prev) })
const cfg = "/tmp/shared-cfg"
edition.Override(&edition.Hooks{Name: "open"})
openPath := GetAppConfigPath(cfg)
edition.Override(&edition.Hooks{Name: "wukong"})
wukongPath := GetAppConfigPath(cfg)
if openPath == wukongPath {
t.Fatalf("open and wukong editions share path %q; cross-edition leakage possible", openPath)
}
if filepath.Dir(openPath) != filepath.Dir(wukongPath) {
t.Fatalf("paths landed in different directories (%q vs %q); partitioning should only differ by filename", filepath.Dir(openPath), filepath.Dir(wukongPath))
}
}
func TestAppConfigIO_OpenEditionDoesNotReadSiblingCredentials(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
edition.Override(&edition.Hooks{Name: "wukong"})
wukongPath := GetAppConfigPath(configDir)
if err := os.WriteFile(wukongPath, []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}`+"\n"), 0600); err != nil {
t.Fatalf("writing sibling app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "open"})
got, err := LoadAppConfig(configDir)
if err != nil {
t.Fatalf("LoadAppConfig(open) error = %v", err)
}
if got != nil {
t.Fatalf("open edition read sibling app config: %#v", got)
}
}
func TestSaveAppConfig_SiblingEditionRemovesMatchingLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("matching legacy app config should be removed, stat error = %v", err)
}
if _, err := os.Stat(filepath.Join(configDir, "app-wukong.json")); err != nil {
t.Fatalf("sibling app config not written: %v", err)
}
}
func TestSaveAppConfig_SiblingEditionKeepsDifferentLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"open-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("different legacy app config should be preserved: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
func TestSaveAppConfig_SiblingEditionKeepsMalformedLegacyAppConfig(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid"`)
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing malformed legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "wukong-cid"}); err != nil {
t.Fatalf("SaveAppConfig(wukong) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("malformed legacy app config should be preserved: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("malformed legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
func TestSaveAppConfig_OpenEditionDoesNotCleanSiblingAppConfigs(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
siblingFiles := map[string][]byte{
"app-wukong.json": []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
"app-dev.json": []byte(`{"clientId":"dev-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n"),
}
for name, data := range siblingFiles {
if err := os.WriteFile(filepath.Join(configDir, name), data, 0600); err != nil {
t.Fatalf("writing sibling app config %s: %v", name, err)
}
}
edition.Override(&edition.Hooks{Name: "open"})
if err := SaveAppConfig(configDir, &AppConfig{ClientID: "open-cid"}); err != nil {
t.Fatalf("SaveAppConfig(open) error = %v", err)
}
for name, want := range siblingFiles {
got, err := os.ReadFile(filepath.Join(configDir, name))
if err != nil {
t.Fatalf("open edition should preserve sibling app config %s: %v", name, err)
}
if string(got) != string(want) {
t.Fatalf("sibling app config %s changed: got %q, want %q", name, got, want)
}
}
}
func TestSaveAppConfig_SiblingEditionKeepsLegacyAppConfigWhenClientIDEmpty(t *testing.T) {
prev := edition.Get()
t.Cleanup(func() {
edition.Override(prev)
resetAppConfigCache()
})
configDir := t.TempDir()
legacyPath := filepath.Join(configDir, appConfigFile)
legacyJSON := []byte(`{"clientId":"wukong-cid","createdAt":"2026-05-17T00:00:00+08:00"}` + "\n")
if err := os.WriteFile(legacyPath, legacyJSON, 0600); err != nil {
t.Fatalf("writing legacy app config: %v", err)
}
edition.Override(&edition.Hooks{Name: "wukong"})
if err := SaveAppConfig(configDir, &AppConfig{}); err != nil {
t.Fatalf("SaveAppConfig(wukong empty client ID) error = %v", err)
}
got, err := os.ReadFile(legacyPath)
if err != nil {
t.Fatalf("legacy app config should be preserved when client ID is empty: %v", err)
}
if string(got) != string(legacyJSON) {
t.Fatalf("legacy app config changed: got %q, want %q", got, legacyJSON)
}
}
+45 -9
View File
@@ -19,6 +19,7 @@ import (
"os"
"strings"
"sync"
"unicode"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
@@ -122,19 +123,51 @@ func readStdinBounded() (string, error) {
return string(data), nil
}
// looksLikeFilePath returns true when value should be interpreted as the
// `@<path>` file-injection syntax. Heuristic: value must start with '@', and
// the character right after '@' must be ASCII (letter, digit, or one of the
// common path-prefix characters: . / ~ _ -). This rules out mistaken matches
// for natural-language messages that happen to start with '@' followed by
// non-ASCII text — e.g. "@所有人" should be a chat mention, not a file path.
func looksLikeFilePath(value string) bool {
if !strings.HasPrefix(value, "@") || len(value) < 2 {
return false
}
rest := value[1:]
if rest == "-" {
return true // @- = stdin
}
first := rune(rest[0])
if first > unicode.MaxASCII {
// First byte is part of a multi-byte rune (e.g. Chinese) — not a path.
return false
}
switch {
case first >= 'A' && first <= 'Z',
first >= 'a' && first <= 'z',
first >= '0' && first <= '9',
first == '.', first == '/', first == '~', first == '_', first == '-':
return true
}
return false
}
// ReadFileArg reads the contents of a file referenced by the @filename syntax.
// Returns the original value unchanged if it does not start with "@".
// Returns the original value unchanged if it does not start with "@" or is
// otherwise not a file-path-shaped value (e.g. "@所有人" is treated as plain
// text, not a path).
// Returns an error if the file cannot be read or exceeds the size limit.
//
// Note: @- (stdin) is NOT handled here; use ResolveInputSource instead.
func ReadFileArg(value string) (string, bool, error) {
if !strings.HasPrefix(value, "@") {
// Preserve the historical bare-"@" behaviour (empty filename → error).
if value == "@" {
return "", false, apperrors.NewValidation("@file: filename must not be empty")
}
if !looksLikeFilePath(value) {
return value, false, nil
}
path := value[1:]
if path == "" {
return "", false, apperrors.NewValidation("@file: filename must not be empty")
}
// @- is stdin, not a file — callers should use ResolveInputSource.
if path == "-" {
return value, false, nil
@@ -156,14 +189,17 @@ func ReadFileArg(value string) (string, bool, error) {
//
// The flagName parameter is used only for error messages and StdinGuard tracking.
func ResolveInputSource(value string, flagName string, guard *StdinGuard) (string, error) {
if !strings.HasPrefix(value, "@") {
// Preserve the historical bare-"@" behaviour (empty filename → error).
if value == "@" {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
}
if !looksLikeFilePath(value) {
// Pass through natural-language strings that happen to start with
// '@' (e.g. "@所有人 早上好") so they reach the MCP payload intact.
return value, nil
}
path := value[1:]
if path == "" {
return "", apperrors.NewValidation(fmt.Sprintf("--%s: @file filename must not be empty", flagName))
}
// @- reads from stdin.
if path == "-" {
+32
View File
@@ -47,6 +47,38 @@ func TestReadFileArgPlainValue(t *testing.T) {
}
}
// TestReadFileArgChineseAtMention guards the @所有人-style mentions that the
// chat bot Webhook tests rely on: an '@' followed by non-ASCII text must be
// treated as a literal message, not as the @file injection syntax.
func TestReadFileArgChineseAtMention(t *testing.T) {
t.Parallel()
cases := []string{
"@所有人 这是 @ 所有人 的消息",
"@张三",
"@A 但接下来都是中文@测试",
}
for _, in := range cases {
val, isFile, err := ReadFileArg(in)
if in == "@A 但接下来都是中文@测试" {
// '@A' starts with ASCII, treated as path → expect file error
if err == nil {
t.Errorf("@A... should attempt file lookup; got val=%q isFile=%v", val, isFile)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error %v", in, err)
continue
}
if isFile {
t.Errorf("%q: should be plain text, got isFile=true", in)
}
if val != in {
t.Errorf("%q: got %q", in, val)
}
}
}
func TestReadFileArgReadsFile(t *testing.T) {
t.Parallel()
+105 -15
View File
@@ -236,10 +236,18 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
for _, b := range built {
if b.parent == "" {
name := b.cmd.Name()
if _, exists := topLevel[name]; !exists {
if existing, exists := topLevel[name]; exists {
// Multiple servers contribute the same top-level command
// (e.g. group-chat and im both register `dws chat`). Move
// the incoming command's *children* into the existing top-
// level command instead of attaching the whole command (which
// would create `dws chat chat` because attachOrMerge would
// AddCommand(b.cmd) when no same-named sub exists).
mergeSubcommandsInto(existing, b.cmd)
} else {
topOrder = append(topOrder, name)
topLevel[name] = b.cmd
}
topLevel[name] = b.cmd
} else {
children = append(children, b)
}
@@ -248,12 +256,16 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
if parent, ok := topLevel[child.parent]; ok {
attachOrMerge(parent, child.cmd)
} else {
// Parent not found among dynamic commands; emit as top-level.
// Parent not found among dynamic commands; emit as top-level,
// merging into an existing same-named top-level command if one
// is already registered (same reasoning as the loop above).
name := child.cmd.Name()
if _, exists := topLevel[name]; !exists {
if existing, exists := topLevel[name]; exists {
mergeSubcommandsInto(existing, child.cmd)
} else {
topOrder = append(topOrder, name)
topLevel[name] = child.cmd
}
topLevel[name] = child.cmd
}
}
@@ -283,10 +295,12 @@ type toolRequestSchema struct {
}
type toolRequestProp struct {
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Default string `json:"default,omitempty"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
Default string `json:"default,omitempty"`
Format string `json:"format,omitempty"`
Enum []string `json:"enum,omitempty"`
}
// buildFlagsFromDetailSchema adds properly-typed cobra flags to cmd based on
@@ -371,6 +385,17 @@ func buildFlagsFromDetailSchema(cmd *cobra.Command, schemaJSON string, flagOverr
cmd.Flags().String(flagName, defaultVal, help)
}
// Carry schema "format" / "enum" hints onto the cobra flag via
// pflag annotations so PreParse handlers (e.g. StickyHandler)
// can reason about whether a glued suffix looks like a real
// value. The annotation keys are read by FlagInfoFromCommand.
if prop.Format != "" {
_ = cmd.Flags().SetAnnotation(flagName, "x-cli-format", []string{prop.Format})
}
if len(prop.Enum) > 0 {
_ = cmd.Flags().SetAnnotation(flagName, "x-cli-enum", append([]string{}, prop.Enum...))
}
if requiredSet[key] {
_ = cmd.MarkFlagRequired(flagName)
}
@@ -478,6 +503,24 @@ func resolveNestedGroup(root *cobra.Command, groupPath string, registry map[stri
return ensureNestedGroup(root, groupPath, groupPath, registry)
}
// mergeSubcommandsInto moves all sub-commands of src into dst, using
// attachOrMerge so subtree merges happen recursively. src itself is left
// empty after the call. Used when two envelope entries register the same
// top-level command (e.g. group-chat and im both `cli.command="chat"`):
// we want their *children* to coexist under one chat root, not have one
// nested inside the other.
func mergeSubcommandsInto(dst, src *cobra.Command) {
if dst == nil || src == nil {
return
}
subs := make([]*cobra.Command, len(src.Commands()))
copy(subs, src.Commands())
for _, sub := range subs {
src.RemoveCommand(sub)
attachOrMerge(dst, sub)
}
}
// attachOrMerge adds child as a sub-command of parent. If parent already has a
// sub-command with the same Name(), the two are merged recursively: child's
// sub-commands are moved onto the existing one and child itself is discarded.
@@ -532,10 +575,19 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
var bindings []FlagBinding
type transformEntry struct {
paramName string
mapsTo string
transform string
transformArgs map[string]any
}
var transforms []transformEntry
// mapsToRoutes captures flags that only need value-routing (no transform)
// — e.g. a literal --content flag that mapsTo "markdown". The dispatch
// loop moves params[paramName] → params[mapsTo] after CLI binding.
type mapsToRoute struct {
paramName string
mapsTo string
}
var mapsToRoutes []mapsToRoute
type envDefaultEntry struct {
paramName string
envVar string
@@ -664,9 +716,19 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
if flagOverride.Transform != "" {
transforms = append(transforms, transformEntry{
paramName: paramName,
mapsTo: strings.TrimSpace(flagOverride.MapsTo),
transform: flagOverride.Transform,
transformArgs: flagOverride.TransformArgs,
})
} else if mt := strings.TrimSpace(flagOverride.MapsTo); mt != "" {
// mapsTo without transform: just route the literal value into a
// different MCP parameter slot. Common case is --content (literal
// string) mapping to MCP parameter markdown, alongside a sibling
// --content-file (transform: file_read) mapping to the same slot.
mapsToRoutes = append(mapsToRoutes, mapsToRoute{
paramName: paramName,
mapsTo: mt,
})
}
if flagOverride.EnvDefault != "" {
envDefaults = append(envDefaults, envDefaultEntry{
@@ -699,12 +761,15 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
}
}
bodyWrapper := strings.TrimSpace(override.BodyWrapper)
if len(transforms) == 0 && len(envDefaults) == 0 && len(defaultInjects) == 0 && len(runtimeDefaults) == 0 && len(omits) == 0 && !needsDottedNesting && bodyWrapper == "" {
if len(transforms) == 0 && len(envDefaults) == 0 && len(defaultInjects) == 0 && len(runtimeDefaults) == 0 && len(omits) == 0 && len(mapsToRoutes) == 0 && !needsDottedNesting && bodyWrapper == "" {
return bindings, nil
}
// Build a normalizer that applies default injections + env defaults + runtime defaults
// + transforms + omitWhen + nesting + body wrap.
// Build a normalizer that applies default injections + env defaults +
// runtime defaults + transforms + mapsTo routing + omitWhen + nesting +
// body wrap. Tool-level cobra constraints (MutuallyExclusive /
// RequireOneOf) are wired separately via applyFlagConstraints and don't
// belong in this closure.
normalizer := func(cmd *cobra.Command, params map[string]any) error {
// §v3.2: Apply envelope flag.default for parameters not explicitly set.
// Coerce by Kind so number-typed schemas don't reject string defaults.
@@ -756,14 +821,21 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
}
}
// §3: Apply transforms
// §3: Apply transforms. When MapsTo is set, the transformed value is
// routed to params[MapsTo] and the original params[paramName] is
// dropped, so the MCP body carries a single (post-transform) entry
// at the target slot.
for _, t := range transforms {
val, exists := params[t.paramName]
if !exists {
// For enum_map with _default, apply default even when flag is omitted
if t.transform == "enum_map" && t.transformArgs != nil {
if defaultVal, hasDefault := t.transformArgs["_default"]; hasDefault {
params[t.paramName] = defaultVal
target := t.paramName
if t.mapsTo != "" {
target = t.mapsTo
}
params[target] = defaultVal
}
}
continue
@@ -772,7 +844,25 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
if err != nil {
return err
}
params[t.paramName] = transformed
if t.mapsTo != "" {
params[t.mapsTo] = transformed
delete(params, t.paramName)
} else {
params[t.paramName] = transformed
}
}
// §3b: mapsTo-only routes (no transform). Move params[paramName] →
// params[mapsTo] verbatim. Common pattern: a literal --content flag
// that routes to MCP parameter `markdown`, alongside a sibling
// --content-file flag that transforms + routes to the same slot.
for _, r := range mapsToRoutes {
val, exists := params[r.paramName]
if !exists {
continue
}
params[r.mapsTo] = val
delete(params, r.paramName)
}
// §v3.2.2: Apply omitWhen — drop keys whose value meets the omit
+282
View File
@@ -15,6 +15,8 @@ package compat
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
@@ -1945,3 +1947,283 @@ func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
t.Fatalf("expected exactly one 'send' leaf, got %d", sendCount)
}
}
// TestBuildFlagsFromDetailSchema_FormatEnumAnnotations verifies that the
// JSON Schema "format" and "enum" hints are copied onto the cobra flag's
// pflag annotations under x-cli-format / x-cli-enum, so PreParse
// handlers can use them when deciding whether to split glued tokens.
func TestBuildFlagsFromDetailSchema_FormatEnumAnnotations(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-calendar",
CLI: market.CLIOverlay{
ID: "calendar",
Command: "calendar",
ToolOverrides: map[string]market.CLIToolOverride{
"event_list": {CLIName: "list"},
},
},
},
}
details := map[string][]market.DetailTool{
"calendar": {
{
ToolName: "event_list",
ToolRequest: `{"properties":{` +
`"start":{"type":"string","format":"date-time","description":"开始时间"},` +
`"end":{"type":"string","format":"date-time","description":"结束时间"},` +
`"status":{"type":"string","enum":["confirmed","tentative","cancelled"]}` +
`}}`,
},
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found")
}
startFlag := list.Flags().Lookup("start")
if startFlag == nil {
t.Fatal("--start flag missing")
}
if got := startFlag.Annotations["x-cli-format"]; len(got) != 1 || got[0] != "date-time" {
t.Errorf("--start x-cli-format = %v, want [date-time]", got)
}
endFlag := list.Flags().Lookup("end")
if endFlag == nil {
t.Fatal("--end flag missing")
}
if got := endFlag.Annotations["x-cli-format"]; len(got) != 1 || got[0] != "date-time" {
t.Errorf("--end x-cli-format = %v, want [date-time]", got)
}
statusFlag := list.Flags().Lookup("status")
if statusFlag == nil {
t.Fatal("--status flag missing")
}
gotEnum := statusFlag.Annotations["x-cli-enum"]
wantEnum := []string{"confirmed", "tentative", "cancelled"}
if !equalStringSlice(gotEnum, wantEnum) {
t.Errorf("--status x-cli-enum = %v, want %v", gotEnum, wantEnum)
}
// Status has no format and should not carry x-cli-format.
if got := statusFlag.Annotations["x-cli-format"]; len(got) != 0 {
t.Errorf("--status should not have x-cli-format, got %v", got)
}
}
// TestBuildDynamicCommands_MapsTo_WithoutTransform verifies that a flag
// carrying only MapsTo (no transform) moves its literal value to the
// target MCP parameter slot and drops the source key. The canonical use
// case is exposing --content as a sibling of --markdown that both feed
// the same upstream `markdown` parameter.
func TestBuildDynamicCommands_MapsTo_WithoutTransform(t *testing.T) {
t.Parallel()
runner := &captureRunner{}
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-doc",
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"update_document": {
CLIName: "update",
Flags: map[string]market.CLIFlagOverride{
"nodeId": {Alias: "node"},
"content": {Alias: "content", MapsTo: "markdown"},
},
},
},
},
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "# 标题"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
if err := cmds[0].Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if runner.lastParams["markdown"] != "# 标题" {
t.Errorf("params[markdown] = %v, want '# 标题'", runner.lastParams["markdown"])
}
if _, leftover := runner.lastParams["content"]; leftover {
t.Errorf("source key 'content' must be deleted after mapsTo, got params=%+v", runner.lastParams)
}
}
// TestBuildDynamicCommands_MapsTo_WithFileReadTransform verifies the full
// envelope shape that #277 needs: a path-typed flag (--content-file) that
// reads the file via the file_read transform AND routes the resulting
// string into a sibling MCP parameter (markdown). End-to-end: user types
// a path, the upstream tool receives file contents under the right key.
func TestBuildDynamicCommands_MapsTo_WithFileReadTransform(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "note.md")
contents := "# 项目周报\n\n- 完成 A\n- 完成 B\n"
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
runner := &captureRunner{}
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-doc",
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"update_document": {
CLIName: "update",
Flags: map[string]market.CLIFlagOverride{
"nodeId": {Alias: "node"},
"contentFile": {
Alias: "content-file",
MapsTo: "markdown",
Transform: "file_read",
},
},
},
},
},
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content-file", path})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
if err := cmds[0].Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if runner.lastParams["markdown"] != contents {
t.Errorf("params[markdown] = %v, want file contents", runner.lastParams["markdown"])
}
if _, leftover := runner.lastParams["contentFile"]; leftover {
t.Errorf("source key 'contentFile' must be deleted after mapsTo, got params=%+v", runner.lastParams)
}
}
// TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne verifies the
// realistic pre-prod shape: two sibling flags (--content literal and
// --content-file path) both mapsTo "markdown", guarded by the existing
// tool-level cobra MutuallyExclusive constraint. When the user sets only
// one, it routes through cleanly; the other source key is absent.
func TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne(t *testing.T) {
t.Parallel()
runner := &captureRunner{}
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-doc",
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"update_document": {
CLIName: "update",
Flags: map[string]market.CLIFlagOverride{
"nodeId": {Alias: "node"},
"content": {Alias: "content", MapsTo: "markdown"},
"contentFile": {
Alias: "content-file",
MapsTo: "markdown",
Transform: "file_read",
},
},
MutuallyExclusive: [][]string{{"content", "content-file"}},
},
},
},
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "literal body"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
if err := cmds[0].Execute(); err != nil {
t.Fatalf("execute: %v", err)
}
if runner.lastParams["markdown"] != "literal body" {
t.Errorf("params[markdown] = %v, want 'literal body'", runner.lastParams["markdown"])
}
if _, leftover := runner.lastParams["content"]; leftover {
t.Errorf("source key 'content' must be deleted, got params=%+v", runner.lastParams)
}
if _, leftover := runner.lastParams["contentFile"]; leftover {
t.Errorf("untouched sibling key 'contentFile' must not appear, got params=%+v", runner.lastParams)
}
}
// TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra verifies that
// when both mapsTo siblings are set, the existing tool-level
// MutuallyExclusive constraint produces a cobra error before dispatch
// runs. This is a sanity regression check — the cobra mechanism is
// pre-existing, but combining it with mapsTo is the realistic envelope
// shape #277 needs.
func TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra(t *testing.T) {
t.Parallel()
runner := &captureRunner{}
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-doc",
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"update_document": {
CLIName: "update",
Flags: map[string]market.CLIFlagOverride{
"nodeId": {Alias: "node"},
"content": {Alias: "content", MapsTo: "markdown"},
"contentFile": {Alias: "content-file", MapsTo: "markdown", Transform: "file_read"},
},
MutuallyExclusive: [][]string{{"content", "content-file"}},
},
},
},
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "x", "--content-file", "/tmp/y"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
err := cmds[0].Execute()
if err == nil {
t.Fatal("expected mutually-exclusive error, got nil")
}
msg := err.Error()
if !strings.Contains(msg, "none of the others") && !strings.Contains(msg, "mutually") && !strings.Contains(msg, "exclusive") {
t.Fatalf("expected mutually-exclusive error, got %v", err)
}
}
// equalStringSlice is a small helper for slice comparison in tests.
func equalStringSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+6 -6
View File
@@ -149,12 +149,12 @@ func executePipelineCall(
if result.Response == nil {
return map[string]any{}, nil
}
// Fail-fast on MCP business errors. Pre-execution validation (e.g.
// cobra MarkFlagRequired) only checks that the flag was set, not
// that the value is non-empty — so a `--required-flag ""` reaches
// here and the upstream tool rejects with errorCode. Without this
// check the pipeline would happily proceed to poll/download and
// either spin until PollTimeout or burn through retries.
// Fail-fast on MCP business errors. Pre-execution validation (cobra
// MarkFlagRequired) only checks that the flag was set, not that
// the value is non-empty — so a `--required-flag ""` reaches here
// and the upstream tool rejects with errorCode. Without this check
// the pipeline proceeds to poll/download and either spins until
// PollTimeout or burns through retries.
if errCode := getDotPath(result.Response, "content.errorCode"); errCode != nil && fmt.Sprint(errCode) != "" {
msg := getDotPath(result.Response, "content.errorMessage")
return nil, apperrors.NewValidation(fmt.Sprintf(
+2 -2
View File
@@ -169,10 +169,10 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
strictMin = b.PositionalIndex + 1
}
}
var argsValidator cobra.PositionalArgs = cobra.NoArgs
var argsValidator cobra.PositionalArgs = cobra.ArbitraryArgs
switch {
case totalMax == 0:
argsValidator = cobra.NoArgs
argsValidator = cobra.ArbitraryArgs
case strictMin > 0 && strictMin == totalMax:
argsValidator = cobra.MinimumNArgs(strictMin)
case strictMin > 0:
+69 -1
View File
@@ -16,9 +16,12 @@ package compat
import (
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"unicode/utf8"
"gopkg.in/yaml.v3"
@@ -27,7 +30,7 @@ import (
// ApplyTransform applies a named transform rule to a value.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
// json_parse_strict, enum_map.
// json_parse_strict, enum_map, file_read, invert_bool.
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
switch strings.TrimSpace(transform) {
case "":
@@ -42,6 +45,33 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
return transformJSONParseStrict(value)
case "enum_map":
return transformEnumMap(value, args)
case "file_read":
return transformFileRead(value)
case "invert_bool":
return transformInvertBool(value)
default:
return value, nil
}
}
// transformInvertBool flips a boolean: true → false, false → true. Strings
// "true"/"false" (any case) are accepted. Used by envelope flags whose CLI
// surface and MCP body have opposite semantics — e.g. `--off` (CLI) maps to
// `mute=true` (MCP) for "mute is enabled", so the flag override declares
// `transform: invert_bool` and the framework flips at send time.
func transformInvertBool(value any) (any, error) {
switch v := value.(type) {
case bool:
return !v, nil
case string:
s := strings.ToLower(strings.TrimSpace(v))
switch s {
case "true", "1", "yes", "on":
return false, nil
case "false", "0", "no", "off", "":
return true, nil
}
return value, nil
default:
return value, nil
}
@@ -194,6 +224,44 @@ func transformEnumMap(value any, args map[string]any) (any, error) {
return value, nil
}
// transformFileRead reads the file at the given path and returns its contents
// as a UTF-8 string. The special path "-" reads from stdin.
//
// Typical envelope use is paired with CLIFlagOverride.MapsTo so a path-typed
// CLI flag (e.g. --content-file ./a.md) routes the file contents into a
// content-typed MCP parameter (e.g. markdown), letting a sibling literal
// flag (--content "# 标题") feed the same parameter without conflict.
//
// Errors are surfaced as validation errors so the dispatcher returns exit code 2
// (user input) rather than the generic exit code 1 (transient failure).
func transformFileRead(value any) (any, error) {
s, ok := toString(value)
if !ok {
return nil, apperrors.NewValidation("file_read: expected string path, got non-string value")
}
s = strings.TrimSpace(s)
if s == "" {
return nil, apperrors.NewValidation("file_read: empty path")
}
var buf []byte
var err error
if s == "-" {
buf, err = io.ReadAll(os.Stdin)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read stdin: %v", err))
}
} else {
buf, err = os.ReadFile(s)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: read %q: %v", s, err))
}
}
if !utf8.Valid(buf) {
return nil, apperrors.NewValidation(fmt.Sprintf("file_read: %q is not valid UTF-8", s))
}
return string(buf), nil
}
func toString(v any) (string, bool) {
switch val := v.(type) {
case string:
+162
View File
@@ -14,7 +14,10 @@
package compat
import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
@@ -123,3 +126,162 @@ func TestJSONParse_InvalidInput(t *testing.T) {
t.Fatal("error message should be non-empty")
}
}
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
// read in full and surfaced as a string value. This is the contract the
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
// file contents in place of the path.
func TestFileRead_BasicFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "note.md")
contents := "# Heading\n\n- bullet one\n- bullet two\n"
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
got, err := ApplyTransform(path, "file_read", nil)
if err != nil {
t.Fatalf("file_read should succeed, got err: %v", err)
}
if got != contents {
t.Errorf("file_read should return file contents verbatim; got %q want %q", got, contents)
}
}
// TestFileRead_EmptyPath rejects empty input with a validation error rather
// than silently reading "" / cwd. The dispatcher maps validation errors to
// exit code 2 so the user sees a usage problem.
func TestFileRead_EmptyPath(t *testing.T) {
t.Parallel()
_, err := ApplyTransform("", "file_read", nil)
if err == nil {
t.Fatal("expected validation error for empty path")
}
if !strings.Contains(err.Error(), "file_read") {
t.Errorf("error should mention the transform name, got %q", err.Error())
}
}
// TestFileRead_MissingFile surfaces a clear validation error when the path
// doesn't exist. The previous `os.ReadFile` error is wrapped so the user
// sees what they passed.
func TestFileRead_MissingFile(t *testing.T) {
t.Parallel()
missing := filepath.Join(t.TempDir(), "definitely-not-here.md")
_, err := ApplyTransform(missing, "file_read", nil)
if err == nil {
t.Fatal("expected error for missing file")
}
if !strings.Contains(err.Error(), "definitely-not-here.md") {
t.Errorf("error should mention the missing path, got %q", err.Error())
}
}
// TestFileRead_InvalidUTF8 rejects binary input. Upstream tools expect text
// content and silently shipping a corrupted byte string would mask a real
// user error.
func TestFileRead_InvalidUTF8(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "binary.dat")
if err := os.WriteFile(path, []byte{0xff, 0xfe, 0x00, 0x01}, 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
_, err := ApplyTransform(path, "file_read", nil)
if err == nil {
t.Fatal("expected UTF-8 validation error for binary input")
}
if !strings.Contains(err.Error(), "UTF-8") {
t.Errorf("error should mention UTF-8, got %q", err.Error())
}
}
// TestFileRead_NonString rejects non-string flag values. CLI flags resolve to
// string by default but a misconfigured envelope (e.g. Type: int) shouldn't
// silently no-op.
func TestFileRead_NonString(t *testing.T) {
t.Parallel()
_, err := ApplyTransform(123, "file_read", nil)
if err == nil {
t.Fatal("expected validation error for non-string value")
}
}
// TestFileRead_StdinDashIsAccepted documents the contract: the special value
// "-" is reserved for stdin. We don't test stdin redirection here (that
// requires plumbing os.Stdin replacement which complicates the test) — this
// is a compile-time signal that "-" doesn't path-resolve to a file named "-"
// in the current directory. The end-to-end stdin path is covered in
// test/cli_compat once the envelope ships.
func TestFileRead_StdinDashIsAccepted(t *testing.T) {
t.Parallel()
// Run with stdin redirected from an empty pipe so we don't hang.
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("setup: %v", err)
}
defer r.Close()
if _, err := w.Write([]byte("piped content")); err != nil {
t.Fatalf("setup: %v", err)
}
w.Close()
origStdin := os.Stdin
os.Stdin = r
defer func() { os.Stdin = origStdin }()
got, err := ApplyTransform("-", "file_read", nil)
if err != nil {
t.Fatalf("file_read with '-' should read stdin, got err: %v", err)
}
if got != "piped content" {
t.Errorf("expected stdin contents, got %q", got)
}
}
// TestFileRead_UnknownTransformPassThrough double-checks that the new case
// is gated by name and doesn't regress when the transform name is missing.
func TestFileRead_UnknownTransformPassThrough(t *testing.T) {
t.Parallel()
got, err := ApplyTransform("./some-path", "", nil)
if err != nil {
t.Fatalf("empty transform should pass through, got err: %v", err)
}
if !reflect.DeepEqual(got, "./some-path") {
t.Errorf("expected pass-through, got %v", got)
}
}
func TestInvertBoolTransform(t *testing.T) {
cases := []struct {
in any
want any
}{
{true, false},
{false, true},
{"true", false},
{"false", true},
{"True", false},
{"FALSE", true},
{"on", false},
{"off", true},
{"", true},
}
for _, c := range cases {
got, err := ApplyTransform(c.in, "invert_bool", nil)
if err != nil {
t.Errorf("ApplyTransform(%v, invert_bool) err=%v", c.in, err)
}
if got != c.want {
t.Errorf("ApplyTransform(%v) = %v, want %v", c.in, got, c.want)
}
}
}
+58 -13
View File
@@ -37,19 +37,20 @@ const (
// Error is the structured repository-local error model for the Go rewrite.
type Error struct {
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Reason string
Hint string
Actions []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
Category Category
Message string
Operation string
ServerKey string
Retryable bool
Reason string
Hint string
Actions []string
AvailableFlags []string
Snapshot string
RPCCode int `json:"rpc_code,omitempty"`
RPCData json.RawMessage `json:"rpc_data,omitempty"`
ServerDiag ServerDiagnostics `json:"-"`
Cause error `json:"-"`
}
func (e *Error) Error() string {
@@ -135,6 +136,16 @@ func WithActions(actions ...string) Option {
}
}
// WithAvailableFlags records visible local flag names for agent recovery.
func WithAvailableFlags(names ...string) Option {
return func(err *Error) {
if len(names) == 0 {
return
}
err.AvailableFlags = append([]string{}, names...)
}
}
// WithSnapshot records the recovery snapshot path associated with the failure.
func WithSnapshot(path string) Option {
return func(err *Error) {
@@ -260,6 +271,9 @@ func PrintJSON(w io.Writer, err error) error {
if len(typed.Actions) > 0 {
errorPayload["actions"] = typed.Actions
}
if len(typed.AvailableFlags) > 0 {
errorPayload["available_flags"] = typed.AvailableFlags
}
if typed.Snapshot != "" {
errorPayload["snapshot_path"] = typed.Snapshot
}
@@ -359,6 +373,9 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
lines = append(lines, fmt.Sprintf("Action: %s", action))
}
}
if line := formatAvailableFlagsHumanLine(typed.AvailableFlags); line != "" {
lines = append(lines, line)
}
if typed.Retryable {
lines = append(lines, "Retryable: true")
}
@@ -414,3 +431,31 @@ func category(err error) string {
}
return string(CategoryInternal)
}
const availableFlagsHumanMaxRunes = 200
func formatAvailableFlagsHumanLine(flags []string) string {
if len(flags) == 0 {
return ""
}
b := strings.Builder{}
b.WriteString("Flags: ")
written := 0
for i, name := range flags {
if i > 0 {
if written+2 > availableFlagsHumanMaxRunes {
b.WriteString("...")
return b.String()
}
b.WriteString(", ")
written += 2
}
if written+len(name) > availableFlagsHumanMaxRunes {
b.WriteString("...")
return b.String()
}
b.WriteString(name)
written += len(name)
}
return b.String()
}
+21
View File
@@ -77,6 +77,27 @@ func TestPrintJSON(t *testing.T) {
}
}
func TestPrintJSON_AvailableFlags(t *testing.T) {
t.Parallel()
var b strings.Builder
if err := PrintJSON(&b, NewValidation(
"unknown flag: --foo",
WithReason("unknown_flag"),
WithHint("Did you mean --bar?"),
WithAvailableFlags("bar", "baz"),
)); err != nil {
t.Fatalf("PrintJSON() error = %v", err)
}
got := b.String()
if !strings.Contains(got, `"available_flags"`) {
t.Fatalf("expected available_flags in output, got %q", got)
}
if !strings.Contains(got, `"bar"`) || !strings.Contains(got, `"baz"`) {
t.Fatalf("expected flag names in output, got %q", got)
}
}
func TestPrintHuman(t *testing.T) {
t.Parallel()
+80
View File
@@ -17,6 +17,7 @@ import (
"encoding/json"
stderrors "errors"
"fmt"
"net/url"
"strings"
"sync"
)
@@ -107,6 +108,8 @@ const ExitCodePermission = 4
// server-provided authorization link. Hosts must treat it as opaque and open
// it verbatim instead of parsing and reconstructing it locally, because
// required parameters may live in query, encoded hash, or fragment sections.
// New hosts may prefer data.authorizationUrl when present; it preserves data.uri
// while adding a copy/open-safe URL for legacy DingTalk hash-route variants.
type PATError struct {
RawJSON string
}
@@ -349,6 +352,9 @@ func ApplyHostMutations(out map[string]any) {
data = map[string]any{}
out["data"] = data
}
if rawURI, ok := data["uri"].(string); ok && strings.TrimSpace(rawURI) != "" {
data["authorizationUrl"] = PATAuthorizationURL(rawURI)
}
if block := HostControlBlock(); block != nil {
delete(data, "callbacks")
data["hostControl"] = block
@@ -356,6 +362,80 @@ func ApplyHostMutations(out map[string]any) {
data["openBrowser"] = PATOpenBrowserValue()
}
// PATAuthorizationURL returns the best URL for hosts to open or show to users.
// It keeps already-complete PAT URLs unchanged. For DingTalk's legacy
// /fe/old#%2FpersonalAuthorization?... hash-route form, it adds the explicit
// hash query and decoded fragment route used by the working authorization page.
func PATAuthorizationURL(rawURI string) string {
rawURI = strings.TrimSpace(rawURI)
if rawURI == "" {
return ""
}
parsed, err := url.Parse(rawURI)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return rawURI
}
if !strings.HasSuffix(parsed.Path, "/fe/old") {
return rawURI
}
if parsed.Query().Get("hash") != "" && strings.Contains(parsed.Fragment, "personalAuthorization") {
return rawURI
}
routeQuery := patAuthorizationRouteQuery(parsed)
if routeQuery.Get("flowId") == "" || routeQuery.Get("userCode") == "" {
return rawURI
}
route := "/personalAuthorization?" + routeQuery.Encode()
next := *parsed
query := next.Query()
query.Set("hash", "#"+route)
next.RawQuery = query.Encode()
next.Fragment = route
next.RawFragment = ""
return next.String()
}
func patAuthorizationRouteQuery(parsed *url.URL) url.Values {
candidates := []string{
parsed.Fragment,
parsed.RawFragment,
parsed.Query().Get("hash"),
}
for _, candidate := range candidates {
if values := parsePersonalAuthorizationRouteQuery(candidate); values.Get("flowId") != "" && values.Get("userCode") != "" {
return values
}
if decoded, err := url.QueryUnescape(candidate); err == nil && decoded != candidate {
if values := parsePersonalAuthorizationRouteQuery(decoded); values.Get("flowId") != "" && values.Get("userCode") != "" {
return values
}
}
}
return nil
}
func parsePersonalAuthorizationRouteQuery(route string) url.Values {
route = strings.TrimSpace(route)
route = strings.TrimPrefix(route, "#")
idx := strings.Index(route, "personalAuthorization?")
if idx < 0 {
return nil
}
rawQuery := route[idx+len("personalAuthorization?"):]
if cut := strings.IndexAny(rawQuery, "?#"); cut >= 0 {
rawQuery = rawQuery[:cut]
}
values, err := url.ParseQuery(rawQuery)
if err != nil {
return nil
}
return values
}
func cleanPATJSON(body map[string]any, code string) string {
out := map[string]any{
"success": false,
+85
View File
@@ -16,6 +16,7 @@ package errors
import (
"encoding/json"
stderrors "errors"
"net/url"
"strings"
"testing"
)
@@ -737,6 +738,90 @@ func TestCleanPATJSON_PreservesOpaqueURIVerbatim(t *testing.T) {
if got, _ := data["uri"].(string); got != rawURI {
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
}
if got, _ := data["authorizationUrl"].(string); got != rawURI {
t.Fatalf("data.authorizationUrl = %q, want %q", got, rawURI)
}
}
func TestPATAuthorizationURL_NormalizesLegacyHashRoute(t *testing.T) {
t.Parallel()
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2"
want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2#/personalAuthorization?flowId=77108a9d0e6f4b74b769c04eb451e7d9&userCode=WSAX-EEF2"
if got := PATAuthorizationURL(rawURI); got != want {
t.Fatalf("PATAuthorizationURL() = %q, want %q", got, want)
}
}
func TestPATAuthorizationURL_NormalizesLegacyHashRoutePreservesExtraQuery(t *testing.T) {
t.Parallel()
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D77108a9d0e6f4b74b769c04eb451e7d9%26userCode%3DWSAX-EEF2%26agentCode%3Dcodex%26scene%3Ddesktop%26redirect%3Dhttps%253A%252F%252Fexample.com%252Fcallback%253Fa%253D1"
got := PATAuthorizationURL(rawURI)
if got == rawURI {
t.Fatal("expected legacy hash route to be normalized")
}
parsed, err := url.Parse(got)
if err != nil {
t.Fatalf("parse normalized URL: %v\nurl=%s", err, got)
}
hash := parsed.Query().Get("hash")
if hash == "" {
t.Fatalf("expected normalized URL to include hash query, got: %s", got)
}
if hash != "#"+parsed.Fragment {
t.Fatalf("hash query = %q, want fragment route %q", hash, "#"+parsed.Fragment)
}
rawQuery, ok := strings.CutPrefix(parsed.Fragment, "/personalAuthorization?")
if !ok {
t.Fatalf("fragment = %q, want personalAuthorization route", parsed.Fragment)
}
values, err := url.ParseQuery(rawQuery)
if err != nil {
t.Fatalf("parse normalized route query: %v\nquery=%s", err, rawQuery)
}
want := map[string]string{
"flowId": "77108a9d0e6f4b74b769c04eb451e7d9",
"userCode": "WSAX-EEF2",
"agentCode": "codex",
"scene": "desktop",
"redirect": "https://example.com/callback?a=1",
}
for key, wantValue := range want {
if gotValue := values.Get(key); gotValue != wantValue {
t.Fatalf("route query %s = %q, want %q", key, gotValue, wantValue)
}
}
}
func TestCleanPATJSON_AddsNormalizedAuthorizationURL(t *testing.T) {
t.Parallel()
rawURI := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN"
want := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3D56b12fd3201d4efab9a9138672cf4deb%26userCode%3DCFTC-27ZN#/personalAuthorization?flowId=56b12fd3201d4efab9a9138672cf4deb&userCode=CFTC-27ZN"
body := map[string]any{
"success": false,
"code": "PAT_MEDIUM_RISK_NO_PERMISSION",
"data": map[string]any{
"desc": "在浏览器中打开以下链接进行认证",
"flowId": "56b12fd3201d4efab9a9138672cf4deb",
"uri": rawURI,
},
}
result := cleanPATJSON(body, "PAT_MEDIUM_RISK_NO_PERMISSION")
var parsed map[string]any
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
t.Fatalf("unmarshal cleanPATJSON output: %v\nraw=%s", err, result)
}
data, _ := parsed["data"].(map[string]any)
if got, _ := data["uri"].(string); got != rawURI {
t.Fatalf("data.uri = %q, want verbatim %q", got, rawURI)
}
if got, _ := data["authorizationUrl"].(string); got != want {
t.Fatalf("data.authorizationUrl = %q, want %q", got, want)
}
}
// ---------------------------------------------------------------------------
+87 -296
View File
@@ -15,6 +15,7 @@ package helpers
import (
"context"
"encoding/json"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
@@ -30,6 +31,12 @@ func init() {
})
}
// chatHandler retains only the chat commands that carry real business logic
// (intelligent tool routing, current-user resolution, response normalization,
// or stdin/@file input support that dynamic commands do not yet provide).
// Thin wrappers — search, group rename, group members list/add/remove/add-bot,
// bot search — are now produced by the dynamic service-discovery envelope
// (envelope/pre-discovery.json) so the helper does not have to duplicate them.
type chatHandler struct{}
func (chatHandler) Name() string {
@@ -40,7 +47,7 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "chat",
Short: "群聊 / 消息 / 机器人",
Long: "管理钉钉会话与群聊:创建群、搜索群、查看群成员、添加机器人到群、修改群名称、拉取会话消息、发送群消息、机器人消息与 Webhook。",
Long: "钉钉会话与群聊:发送消息(用户/机器人/Webhook)、撤回机器人消息、创建群。其余命令由服务发现 envelope 提供。",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
@@ -64,11 +71,12 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
newChatMessageSendByBotCommand(runner),
newChatMessageRecallByBotCommand(runner),
newChatMessageSendByWebhookCommand(runner),
newChatMessageReplyCommand(runner),
)
bot := &cobra.Command{
Use: "bot",
Short: "机器人管理",
group := &cobra.Command{
Use: "group",
Short: "群组管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
@@ -76,9 +84,9 @@ func (chatHandler) Command(runner executor.Runner) *cobra.Command {
return cmd.Help()
},
}
bot.AddCommand(newChatBotSearchCommand(runner))
group.AddCommand(newChatGroupCreateCommand(runner))
root.AddCommand(message, newChatSearchCommand(runner), newChatGroupCommand(runner), bot)
root.AddCommand(message, group)
return root
}
@@ -92,12 +100,13 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
--open-dingtalk-id 指定 openDingTalkId 发单聊 (适用于无法获取 userId 的场景)。
三者只能选其一,不能同时指定。
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。必须提供 --title 作为消息标题。
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。
--title 是消息标题,群聊与单聊都必填(API 强制要求;缺失时返回误导性的 "发群服务窗会话消息失败")。
群聊场景下可用 --at-all / --at-users / --at-mobiles 进行 @ 提醒(仅 --group 时生效)。
注意 --text 中需包含对应的 <@userId> / <@all> 占位符才能在客户端渲染出 @ 效果。`,
Example: ` dws chat message send --group <openconversation_id> --text "hello"
dws chat message send --user <userId> --text "请查收"
Example: ` dws chat message send --group <openconversation_id> --title "周报" --text "请提交本周日报"
dws chat message send --user <userId> --title "提醒" --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请确认"
dws chat message send --group <openconversation_id> --title "拉群通知" --text "<@uid> 你被 @ 了" --at-users uid`,
Args: cobra.MaximumNArgs(1),
@@ -127,7 +136,7 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("user", "", "接收人 userId (单聊三选一)")
cmd.Flags().String("open-dingtalk-id", "", "接收人 openDingTalkId (单聊三选一)")
cmd.Flags().String("text", "", "消息内容,支持 Markdown (也可作位置参数)")
cmd.Flags().String("title", "", "消息标题 (可选)")
cmd.Flags().String("title", "", "消息标题 (必填,群聊与单聊都必填)")
cmd.Flags().Bool("at-all", false, "@所有人 (仅 --group 群聊生效)")
cmd.Flags().String("at-users", "", "按 userId @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
cmd.Flags().String("at-mobiles", "", "按手机号 @ 指定成员,逗号分隔 (仅 --group 群聊生效)")
@@ -195,6 +204,16 @@ func buildChatMessageSendInvocation(cmd *cobra.Command, args []string) (map[stri
if !hasGroup && (atAll || hasAtUsers || hasAtMobiles) {
return nil, "", apperrors.NewValidation("--at-all / --at-users / --at-mobiles only apply when --group is set")
}
// Both send_message_as_user (group) and send_direct_message_as_user (direct)
// reject an empty title at the API level with a misleading
// "发群服务窗会话消息失败" error, so fail loudly here instead. The schema
// declares title as a required parameter on both tools.
if strings.TrimSpace(title) == "" {
if hasGroup {
return nil, "", apperrors.NewValidation("--title is required for group messages (--group)")
}
return nil, "", apperrors.NewValidation("--title is required for direct messages (--user / --open-dingtalk-id)")
}
params := map[string]any{"text": text}
if strings.TrimSpace(title) != "" {
@@ -259,94 +278,6 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
return cmd
}
func newChatSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "根据名称搜索会话列表",
Example: ` dws chat search --query "项目冲刺"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
query, err := cmd.Flags().GetString("query")
if err != nil {
return apperrors.NewInternal("failed to read --query")
}
query = strings.TrimSpace(query)
if query == "" {
return apperrors.NewValidation("--query is required")
}
searchReq := map[string]any{"query": query}
cursor, err := cmd.Flags().GetString("cursor")
if err != nil {
return apperrors.NewInternal("failed to read --cursor")
}
if strings.TrimSpace(cursor) != "" {
searchReq["cursor"] = cursor
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd),
"chat",
"search_groups_by_keyword",
map[string]any{"OpenSearchRequest": searchReq},
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("query", "", "搜索关键词 (必填)")
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
return cmd
}
func newChatGroupCommand(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "group",
Short: "群组管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
members := &cobra.Command{
Use: "members",
Short: "群成员管理",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
// Keeps the helper-restructured group winning over the dynamic envelope's
// `members` leaf (which only exposes `get_group_members`); without this
// the merge layer treats the shape mismatch as "envelope is authority"
// and drops the entire helper subtree (issue #164).
preferLegacyLeaf(members)
members.AddCommand(
newChatGroupMembersListCommand(runner),
newChatGroupMemberAddCommand(runner),
newChatGroupMemberRemoveCommand(runner),
newChatGroupMembersAddBotCommand(runner),
)
root.AddCommand(
newChatGroupCreateCommand(runner),
members,
newChatGroupRenameCommand(runner),
)
return root
}
func newChatGroupCreateCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "create",
@@ -636,6 +567,10 @@ func newChatMessageRecallByBotCommand(runner executor.Runner) *cobra.Command {
}
// ── message send-by-webhook ────────────────────────────────
//
// Kept as a helper (rather than delegating to the dynamic envelope) because
// it needs --text @file / stdin pipe support via resolveStringFlag, which the
// dynamic-command layer does not yet provide.
func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
@@ -702,65 +637,63 @@ func newChatMessageSendByWebhookCommand(runner executor.Runner) *cobra.Command {
return cmd
}
// ── group members list ─────────────────────────────────────
// ── message reply ────────────────────────────────────────
//
// Kept as a helper because the underlying MCP tool send_personal_message
// requires the reply payload to be a JSON-encoded string assembled from
// --ref-msg-id / --ref-sender / --text. Envelope toolOverride does flat
// flag→param mapping only and cannot construct nested JSON, so this
// orchestration must live in CLI code.
func newChatGroupMembersListCommand(runner executor.Runner) *cobra.Command {
func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "查询群成员列表",
Example: ` dws chat group members list --id <openconversation_id>`,
Use: "reply",
Short: "引用回复消息(支持单聊/群聊)",
Long: "以当前用户身份引用某条消息并回复。需 --conversation-id 会话 ID、--ref-msg-id 被引用消息 ID、--ref-sender 原发送者 openDingTalkId、--text 回复内容。",
Example: ` dws chat message reply --conversation-id <openConversationId> --ref-msg-id <openMessageId> --ref-sender <openDingTalkId> --text "收到,马上处理"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("id")
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
convID, _ := cmd.Flags().GetString("conversation-id")
refMsgID, _ := cmd.Flags().GetString("ref-msg-id")
refSender, _ := cmd.Flags().GetString("ref-sender")
text, _ := cmd.Flags().GetString("text")
if strings.TrimSpace(convID) == "" {
return apperrors.NewValidation("--conversation-id is required")
}
params := map[string]any{
"openconversation_id": groupID,
if strings.TrimSpace(refMsgID) == "" {
return apperrors.NewValidation("--ref-msg-id is required")
}
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
params["cursor"] = v
if strings.TrimSpace(refSender) == "" {
return apperrors.NewValidation("--ref-sender is required")
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "get_group_members", params,
))
if strings.TrimSpace(text) == "" {
return apperrors.NewValidation("--text is required")
}
replyContent := map[string]any{
"referenceOpenMessageId": refMsgID,
"srcMsgSendOpenDingTalkId": refSender,
"replyMsgType": "text",
"content": text,
}
contentJSON, err := jsonMarshal(replyContent)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
cmd.Flags().String("cursor", "", "分页游标 (首页留空)")
return cmd
}
// ── group rename ───────────────────────────────────────────
func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "rename",
Short: "更新群名称",
Example: ` dws chat group rename --id <openconversation_id> --name "新群名"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("id")
name, _ := cmd.Flags().GetString("name")
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
}
if strings.TrimSpace(name) == "" {
return apperrors.NewValidation("--name is required")
return apperrors.NewInternal("marshal reply content: " + err.Error())
}
params := map[string]any{
"openconversation_id": groupID,
"group_name": name,
"openConversationId": convID,
"msgType": "reply",
"content": contentJSON,
"clawType": "wukong",
}
if uuid, _ := cmd.Flags().GetString("uuid"); strings.TrimSpace(uuid) != "" {
params["uuid"] = uuid
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "update_group_name", params,
cobracmd.LegacyCommandPath(cmd),
"group-chat",
"send_personal_message",
params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
@@ -771,160 +704,18 @@ func newChatGroupRenameCommand(runner executor.Runner) *cobra.Command {
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
cmd.Flags().String("name", "", "新群名称 (必填)")
cmd.Flags().String("conversation-id", "", "会话 openConversationId (必填,支持单聊/群聊)")
cmd.Flags().String("ref-msg-id", "", "被引用的消息 openMessageId (必填)")
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
cmd.Flags().String("text", "", "回复正文 (必填)")
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
return cmd
}
// ── group members add ──────────────────────────────────────
func newChatGroupMemberAddCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add",
Short: "添加群成员",
Example: ` dws chat group members add --id <openconversation_id> --users userId1,userId2`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("id")
usersStr, _ := cmd.Flags().GetString("users")
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
}
if strings.TrimSpace(usersStr) == "" {
return apperrors.NewValidation("--users is required")
}
params := map[string]any{
"openconversation_id": groupID,
"userId": splitCSV(usersStr),
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "add_group_member", params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
func jsonMarshal(v any) (string, error) {
b, err := json.Marshal(v)
if err != nil {
return "", err
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "群 ID / openconversation_id (必填)")
cmd.Flags().String("users", "", "要添加的 userId 列表,逗号分隔 (必填)")
return cmd
}
// ── group members remove ───────────────────────────────────
func newChatGroupMemberRemoveCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "remove",
Short: "移除群成员",
Example: ` dws chat group members remove --id <openconversation_id> --users userId1,userId2`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
groupID, _ := cmd.Flags().GetString("id")
usersStr, _ := cmd.Flags().GetString("users")
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
}
if strings.TrimSpace(usersStr) == "" {
return apperrors.NewValidation("--users is required")
}
params := map[string]any{
"openconversationId": groupID,
"userIdList": splitCSV(usersStr),
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "chat", "remove_group_member", params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("id", "", "Group ID / openconversation_id (required)")
cmd.Flags().String("users", "", "Comma-separated userId list to remove (required)")
return cmd
}
// ── group members add-bot ──────────────────────────────────
func newChatGroupMembersAddBotCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "add-bot",
Short: "Add bot to group",
Example: ` dws chat group members add-bot --robot-code <robot-code> --id <openconversation_id>`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
robotCode, _ := cmd.Flags().GetString("robot-code")
groupID, _ := cmd.Flags().GetString("id")
if strings.TrimSpace(robotCode) == "" {
return apperrors.NewValidation("--robot-code is required")
}
if strings.TrimSpace(groupID) == "" {
return apperrors.NewValidation("--id is required")
}
params := map[string]any{
"robotCode": robotCode,
"openConversationId": groupID,
}
inv := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "add_robot_to_group", params,
)
inv.DryRun = commandDryRun(cmd)
result, err := runner.Run(cmd.Context(), inv)
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("robot-code", "", "Bot code (required)")
cmd.Flags().String("id", "", "Group openConversationId (required)")
return cmd
}
// ── bot search ─────────────────────────────────────────────
func newChatBotSearchCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "Search my bots",
Example: " dws chat bot search --page 1\n dws chat bot search --page 1 --size 10 --name \"日报\"",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
page, _ := cmd.Flags().GetInt("page")
params := map[string]any{
"currentPage": page,
}
if v, _ := cmd.Flags().GetInt("size"); v > 0 {
params["pageSize"] = v
}
if v, _ := cmd.Flags().GetString("name"); v != "" {
params["robotName"] = v
}
result, err := runner.Run(cmd.Context(), executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "bot", "search_my_robots", params,
))
if err != nil {
return err
}
return writeCommandPayload(cmd, result)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().Int("page", 1, "Page number, starting from 1")
cmd.Flags().Int("size", 0, "Items per page (default 50)")
cmd.Flags().String("name", "", "Search by name")
return cmd
return string(b), nil
}
+19 -75
View File
@@ -7,7 +7,6 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
type captureRunner struct {
@@ -60,28 +59,28 @@ func TestChatMessageSendRoutesByDestination(t *testing.T) {
}{
{
name: "group",
args: []string{"--group", "cid-xyz", "--text", "hello"},
args: []string{"--group", "cid-xyz", "--title", "t", "--text", "hello"},
wantTool: "send_message_as_user",
wantKey: "openConversation_id",
wantValue: "cid-xyz",
},
{
name: "user-direct",
args: []string{"--user", "034766", "--text", "hi"},
args: []string{"--user", "034766", "--title", "t", "--text", "hi"},
wantTool: "send_direct_message_as_user",
wantKey: "receiverUserId",
wantValue: "034766",
},
{
name: "open-dingtalk-id-direct",
args: []string{"--open-dingtalk-id", "OP123", "--text", "hi"},
args: []string{"--open-dingtalk-id", "OP123", "--title", "t", "--text", "hi"},
wantTool: "send_direct_message_as_user",
wantKey: "receiverOpenDingTalkId",
wantValue: "OP123",
},
{
name: "positional-text",
args: []string{"--group", "cid-xyz", "hello from positional"},
args: []string{"--group", "cid-xyz", "--title", "t", "hello from positional"},
wantTool: "send_message_as_user",
wantKey: "text",
wantValue: "hello from positional",
@@ -132,6 +131,21 @@ func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
args: []string{"--group", "cid-x"},
wantErr: "--text (or positional argument) is required",
},
{
name: "group-without-title",
args: []string{"--group", "cid-x", "--text", "hi"},
wantErr: "--title is required for group messages",
},
{
name: "direct-user-without-title",
args: []string{"--user", "034766", "--text", "hi"},
wantErr: "--title is required for direct messages",
},
{
name: "direct-open-dingtalk-id-without-title",
args: []string{"--open-dingtalk-id", "OP123", "--text", "hi"},
wantErr: "--title is required for direct messages",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -292,76 +306,6 @@ func equalAny(a, b any) bool {
}
}
// TestChatGroupMembersListSubcommand pins the explicit `list` subcommand
// added for issue #164: previously the bare `chat group members --id` was
// the list path, but it shape-mismatched the dynamic envelope's `members`
// leaf and got eaten by the merge layer. Now `dws chat group members list
// --id <cid>` is a proper leaf siblings of add/remove/add-bot.
func TestChatGroupMembersListSubcommand(t *testing.T) {
runner := &captureRunner{}
groupCmd := newChatGroupCommand(runner)
var members *cobra.Command
for _, sub := range groupCmd.Commands() {
if sub.Name() == "members" {
members = sub
break
}
}
if members == nil {
t.Fatalf("members subcommand missing under chat group")
}
want := map[string]bool{"list": false, "add": false, "remove": false, "add-bot": false}
for _, leaf := range members.Commands() {
if _, ok := want[leaf.Name()]; ok {
want[leaf.Name()] = true
}
}
for name, seen := range want {
if !seen {
t.Errorf("expected `chat group members %s` subcommand, missing", name)
}
}
if members.Flags().Lookup("id") != nil {
t.Errorf("members container should not declare --id (moved to `list` subcommand to avoid shape-mismatch with dynamic envelope)")
}
var listCmd *cobra.Command
for _, leaf := range members.Commands() {
if leaf.Name() == "list" {
listCmd = leaf
break
}
}
if listCmd == nil {
t.Fatalf("`list` subcommand not found")
}
if listCmd.Flags().Lookup("id") == nil {
t.Errorf("`list` subcommand must declare --id")
}
if listCmd.Flags().Lookup("cursor") == nil {
t.Errorf("`list` subcommand must declare --cursor")
}
// Drive execution via the group root so cobra resolves the subcommand
// path properly (calling Execute() on a child directly would re-enter
// the root help branch).
var out bytes.Buffer
groupCmd.SetOut(&out)
groupCmd.SetErr(&out)
groupCmd.SetArgs([]string{"members", "list", "--id", "cid-xyz"})
if err := groupCmd.Execute(); err != nil {
t.Fatalf("members list Execute error = %v\noutput: %s", err, out.String())
}
if got := runner.last.Tool; got != "get_group_members" {
t.Fatalf("Tool = %q, want get_group_members", got)
}
if got := runner.last.Params["openconversation_id"]; got != "cid-xyz" {
t.Fatalf("openconversation_id = %#v, want cid-xyz", got)
}
}
func TestChatMessageSendByBotRoutesToBotProduct(t *testing.T) {
cases := []struct {
name string
+340
View File
@@ -0,0 +1,340 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package helpers
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/spf13/cobra"
)
func init() {
RegisterPublic(func() Handler {
return driveHandler{}
})
}
// driveHandler exposes the one drive subcommand that the service-discovery
// envelope cannot express on its own:
//
// - upload — three-step composite: drive.get_upload_info → HTTP PUT to OSS →
// drive.commit_upload. The envelope PipelineStep schema currently supports
// type:"call" (MCP tool invocation) and type:"download" (HTTP GET sink),
// but has no type:"upload" for streaming a local file to an OSS-signed
// PUT URL with per-URL headers. Until the envelope schema grows that
// capability, this helper is the canonical client-side glue.
//
// Other drive-vs-wukong gaps (list-spaces and delete) are covered purely via
// envelope toolOverrides — list_spaces as a plain alias map, delete_document
// via serverOverride to route to the doc MCP server. No Go code needed for
// those two; see envelope/pre-discovery.pre.json drive entry.
//
// The dynamic envelope still owns the six base commands (list / info /
// download / mkdir / upload-info / commit); pickCommands.MergeHardcodedLeaves
// guarantees dynamic leaves win on collision so this helper only fills the
// upload gap.
type driveHandler struct{}
func (driveHandler) Name() string {
return "drive"
}
func (driveHandler) Command(runner executor.Runner) *cobra.Command {
root := &cobra.Command{
Use: "drive",
Short: "钉盘扩展命令(合并到 dws drive 命令树)",
Long: "钉盘扩展子命令: upload 本地文件一键上传(三步合成)。其余命令均由服务发现 envelope 提供。",
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
root.AddCommand(newDriveUploadCommand(runner))
return root
}
// ── upload (three-step composite) ───────────────────────────
func newDriveUploadCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "upload",
Short: "上传本地文件到钉盘",
Long: `将本地文件上传到钉盘(三步自动完成):
1. drive get_upload_info → 获取 OSS 上传凭证 (resourceUrl + uploadId + headers)
2. HTTP PUT 文件二进制 → OSS
3. drive commit_upload → 提交文件入库
--folder 指定父目录 dentryUuid,不传则上传到空间根目录。`,
Example: ` dws drive upload --file ./report.pdf
dws drive upload --file ./slides.pptx --file-name "Q1汇报.pptx"
dws drive upload --file ./data.xlsx --folder <dentryUuid>`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runDriveUpload(cmd, runner)
},
}
preferLegacyLeaf(cmd)
cmd.Flags().String("file", "", "本地文件路径 (必填)")
cmd.Flags().String("file-name", "", "文件显示名称 (默认使用文件名)")
cmd.Flags().String("space-id", "", "目标空间 ID,不传则使用「我的文件」")
cmd.Flags().String("mime-type", "", "文件 MIME 类型,不传则自动推断")
cmd.Flags().String("folder", "", "父节点 ID (dentryUuid),不传则上传到空间根目录")
return cmd
}
func runDriveUpload(cmd *cobra.Command, runner executor.Runner) error {
filePath, _ := cmd.Flags().GetString("file")
if strings.TrimSpace(filePath) == "" {
return apperrors.NewValidation("--file is required")
}
absPath, err := filepath.Abs(filePath)
if err != nil {
return apperrors.NewValidation("无法解析文件路径: " + err.Error())
}
fi, err := os.Stat(absPath)
if err != nil {
return apperrors.NewValidation("文件不存在或无法读取: " + absPath)
}
if fi.IsDir() {
return apperrors.NewValidation("--file 不能是目录: " + absPath)
}
fileSize := fi.Size()
if fileSize <= 0 {
return apperrors.NewValidation("文件为空")
}
fileName, _ := cmd.Flags().GetString("file-name")
if strings.TrimSpace(fileName) == "" {
fileName = filepath.Base(absPath)
}
spaceID, _ := cmd.Flags().GetString("space-id")
mimeType, _ := cmd.Flags().GetString("mime-type")
if strings.TrimSpace(mimeType) == "" {
mimeType = detectMIME(fileName)
}
parentID, _ := cmd.Flags().GetString("folder")
if err := validateDriveParentID(parentID); err != nil {
return err
}
// Step 1 params
step1Params := map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
}
if strings.TrimSpace(spaceID) != "" {
step1Params["spaceId"] = spaceID
}
if strings.TrimSpace(mimeType) != "" {
step1Params["mimeType"] = mimeType
}
if strings.TrimSpace(parentID) != "" {
step1Params["parentId"] = parentID
}
if commandDryRun(cmd) {
return writeCommandPayload(cmd, map[string]any{
"dry_run": true,
"step_1_get_upload_info": executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "drive", "get_upload_info", step1Params,
),
"step_2_http_put_oss": "PUT file bytes to resourceUrls[0].url with returned headers",
"step_3_commit_upload": "drive commit_upload with uploadId from step 1",
"file": absPath,
"size": fileSize,
"name": fileName,
})
}
// Step 1: get_upload_info
fmt.Fprintf(os.Stderr, "[1/3] 获取上传凭证 %s (%d 字节, %s)...\n", fileName, fileSize, mimeType)
step1 := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "drive", "get_upload_info", step1Params,
)
step1Result, err := runner.Run(cmd.Context(), step1)
if err != nil {
return fmt.Errorf("获取上传凭证失败: %w", err)
}
resourceURL, uploadID, ossHeaders, err := parseDriveUploadInfo(step1Result.Response)
if err != nil {
return err
}
// Step 2: HTTP PUT to OSS
fmt.Fprintln(os.Stderr, "[2/3] 上传文件到 OSS...")
if err := httpPutDriveFile(cmd.Context(), resourceURL, ossHeaders, absPath, fileSize, mimeType); err != nil {
return err
}
// Step 3: commit_upload
fmt.Fprintln(os.Stderr, "[3/3] 提交文件入库...")
step3Params := map[string]any{
"fileName": fileName,
"fileSize": float64(fileSize),
"uploadId": uploadID,
}
if strings.TrimSpace(spaceID) != "" {
step3Params["spaceId"] = spaceID
}
if strings.TrimSpace(parentID) != "" {
step3Params["parentId"] = parentID
}
step3 := executor.NewHelperInvocation(
cobracmd.LegacyCommandPath(cmd), "drive", "commit_upload", step3Params,
)
result, err := runner.Run(cmd.Context(), step3)
if err != nil {
return fmt.Errorf("提交文件入库失败: %w", err)
}
return writeCommandPayload(cmd, result)
}
// validateDriveParentID rejects pure-numeric IDs (which are dentryId values
// from the chat link namespace, not drive's dentryUuid).
func validateDriveParentID(parentID string) error {
value := strings.TrimSpace(parentID)
if value == "" {
return nil
}
for _, r := range value {
if r < '0' || r > '9' {
return nil
}
}
return apperrors.NewValidation(fmt.Sprintf(
"invalid drive --folder %q: pure numeric IDs are usually dentryId values from chat links, not drive dentryUuid; use a parent folder dentryUuid from drive list, or omit --folder to use the space root",
parentID,
))
}
// parseDriveUploadInfo extracts resourceUrl / uploadId / OSS headers from the
// drive.get_upload_info response. The actual server payload is:
//
// {
// "uploadId": "...",
// "resourceUrls": [
// { "url": "https://...", "headers": { ... } }
// ]
// }
//
// MCP gateway may wrap the payload with a "content" or "result" envelope, so we
// peel one layer if present, and also accept legacy flat resourceUrl/uploadUrl
// fields as a fallback.
func parseDriveUploadInfo(resp map[string]any) (resourceURL, uploadID string, headers map[string]string, err error) {
if resp == nil {
err = apperrors.NewValidation("get_upload_info 返回为空")
return
}
data := resp
if content, ok := data["content"].(map[string]any); ok && len(content) > 0 {
data = content
}
if result, ok := data["result"].(map[string]any); ok && len(result) > 0 {
data = result
}
uploadID, _ = data["uploadId"].(string)
if urls, ok := data["resourceUrls"].([]any); ok && len(urls) > 0 {
if first, ok := urls[0].(map[string]any); ok {
resourceURL, _ = first["url"].(string)
headers = make(map[string]string)
if h, ok := first["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
}
}
if resourceURL == "" {
resourceURL, _ = data["resourceUrl"].(string)
}
if resourceURL == "" {
resourceURL, _ = data["uploadUrl"].(string)
}
if resourceURL == "" || uploadID == "" {
err = apperrors.NewValidation(fmt.Sprintf(
"get_upload_info 返回不完整: resourceUrl=%q, uploadId=%q", resourceURL, uploadID,
))
return
}
if headers == nil {
headers = make(map[string]string)
if h, ok := data["headers"].(map[string]any); ok {
for k, v := range h {
if s, ok := v.(string); ok {
headers[k] = s
}
}
}
}
return
}
func httpPutDriveFile(ctx context.Context, resourceURL string, headers map[string]string, filePath string, fileSize int64, fallbackMIME string) error {
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("无法打开文件: %w", err)
}
defer f.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, resourceURL, f)
if err != nil {
return fmt.Errorf("构建 OSS 上传请求失败: %w", err)
}
req.ContentLength = fileSize
hasContentType := false
for k, v := range headers {
req.Header.Set(k, v)
if strings.EqualFold(k, "Content-Type") {
hasContentType = true
}
}
if !hasContentType && fallbackMIME != "" {
req.Header.Set("Content-Type", fallbackMIME)
}
client := &http.Client{Timeout: 10 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("OSS 上传失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("OSS 上传失败 HTTP %d: %s", resp.StatusCode, string(body))
}
return nil
}
+65
View File
@@ -0,0 +1,65 @@
// 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.
//go:build darwin || linux
package keychain
import (
"crypto/rand"
"fmt"
"os"
"path/filepath"
"github.com/google/uuid"
)
// fileDEK retrieves or generates a Data Encryption Key stored as a plain
// file under the platform storage directory. Shared by Linux (default) and
// the macOS sandbox fallback path (DWS_DISABLE_KEYCHAIN=1).
func fileDEK(service string) ([]byte, error) {
dir := StorageDir(service)
keyPath := filepath.Join(dir, "dek")
key, err := os.ReadFile(keyPath)
if err == nil && len(key) == dekBytes {
return key, nil
}
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("create keychain dir: %w", err)
}
key = make([]byte, dekBytes)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generate dek: %w", err)
}
tmpKeyPath := filepath.Join(dir, "dek."+uuid.New().String()+".tmp")
defer os.Remove(tmpKeyPath)
if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil {
return nil, fmt.Errorf("write dek: %w", err)
}
if err := os.Rename(tmpKeyPath, keyPath); err != nil {
// If rename fails, another process might have created it. Try reading again.
existingKey, readErr := os.ReadFile(keyPath)
if readErr == nil && len(existingKey) == dekBytes {
return existingKey, nil
}
return nil, fmt.Errorf("save dek: %w", err)
}
return key, nil
}
+8
View File
@@ -30,6 +30,14 @@ const (
// real user environment and from sibling test packages running in
// parallel. When empty, the platform default applies.
StorageDirEnv = "DWS_KEYCHAIN_DIR"
// DisableKeychainEnv opts the macOS implementation out of system
// Keychain access for the DEK, falling back to a file-based DEK
// (same scheme as Linux). Intended for sandboxed runtimes where
// Keychain APIs are blocked (e.g. Codex App). This weakens the
// at-rest protection — DEK and ciphertext live in the same
// directory — and is therefore opt-in.
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
)
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
+9 -1
View File
@@ -59,8 +59,16 @@ func safeFileName(account string) string {
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
}
// getDEK retrieves or generates the Data Encryption Key from system Keychain.
// getDEK retrieves or generates the Data Encryption Key.
// When DWS_DISABLE_KEYCHAIN=1 (set in sandboxed runtimes like Codex App
// where Keychain APIs are blocked), falls back to a file-based DEK
// identical to the Linux scheme. See DisableKeychainEnv docs for the
// security tradeoff.
func getDEK(service string) ([]byte, error) {
if os.Getenv(DisableKeychainEnv) != "" {
return fileDEK(service)
}
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
defer cancel()
+107
View File
@@ -0,0 +1,107 @@
// 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.
//go:build darwin
package keychain
import (
"os"
"path/filepath"
"testing"
)
// TestDisableKeychainFallback verifies that setting DWS_DISABLE_KEYCHAIN
// routes the DEK to a local file (same scheme as Linux) and the full
// Set/Get/Remove cycle works without touching the system Keychain.
// This is the support path for sandboxed runtimes such as Codex App.
func TestDisableKeychainFallback(t *testing.T) {
tmp := t.TempDir()
t.Setenv(StorageDirEnv, tmp)
t.Setenv(DisableKeychainEnv, "1")
service := "test-disable-keychain"
account := "auth-token"
payload := `{"access_token":"abc","refresh_token":"def"}`
if err := Set(service, account, payload); err != nil {
t.Fatalf("Set() error = %v", err)
}
// File DEK must materialize on disk.
dekPath := filepath.Join(tmp, service, "dek")
info, err := os.Stat(dekPath)
if err != nil {
t.Fatalf("file DEK not created at %s: %v", dekPath, err)
}
if mode := info.Mode().Perm(); mode != 0600 {
t.Fatalf("DEK file perm = %o, want 0600", mode)
}
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got != payload {
t.Fatalf("Get() = %q, want %q", got, payload)
}
// A second Get must reuse the same DEK (no regeneration).
dek1, err := os.ReadFile(dekPath)
if err != nil {
t.Fatalf("ReadFile(dek) error = %v", err)
}
if _, err := Get(service, account); err != nil {
t.Fatalf("second Get() error = %v", err)
}
dek2, err := os.ReadFile(dekPath)
if err != nil {
t.Fatalf("ReadFile(dek) second error = %v", err)
}
if string(dek1) != string(dek2) {
t.Fatal("DEK rotated between calls; want stable")
}
if err := Remove(service, account); err != nil {
t.Fatalf("Remove() error = %v", err)
}
if Exists(service, account) {
t.Fatal("Exists() = true after Remove(), want false")
}
}
// TestDisableKeychainOverwrite verifies the fallback path supports
// overwriting an existing token entry.
func TestDisableKeychainOverwrite(t *testing.T) {
tmp := t.TempDir()
t.Setenv(StorageDirEnv, tmp)
t.Setenv(DisableKeychainEnv, "1")
service := "test-disable-keychain-overwrite"
account := "auth-token"
if err := Set(service, account, "initial"); err != nil {
t.Fatalf("Set() initial error = %v", err)
}
if err := Set(service, account, "overwritten"); err != nil {
t.Fatalf("Set() overwrite error = %v", err)
}
got, err := Get(service, account)
if err != nil {
t.Fatalf("Get() error = %v", err)
}
if got != "overwritten" {
t.Fatalf("Get() = %q, want %q", got, "overwritten")
}
}
+5 -42
View File
@@ -27,6 +27,11 @@ import (
"github.com/google/uuid"
)
// getDEK retrieves or generates the Data Encryption Key from local file.
func getDEK(service string) ([]byte, error) {
return fileDEK(service)
}
const (
dekBytes = 32 // DEK = Data Encryption Key (AES-256)
ivBytes = 12
@@ -56,48 +61,6 @@ func safeFileName(account string) string {
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
}
// getDEK retrieves or generates the Data Encryption Key from local file.
func getDEK(service string) ([]byte, error) {
dir := StorageDir(service)
keyPath := filepath.Join(dir, "dek")
// Try to read existing DEK
key, err := os.ReadFile(keyPath)
if err == nil && len(key) == dekBytes {
return key, nil
}
// Create directory if needed
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("create keychain dir: %w", err)
}
// Generate new random DEK
key = make([]byte, dekBytes)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generate dek: %w", err)
}
// Atomic write to prevent multi-process initialization collision
tmpKeyPath := filepath.Join(dir, "dek."+uuid.New().String()+".tmp")
defer os.Remove(tmpKeyPath)
if err := os.WriteFile(tmpKeyPath, key, 0600); err != nil {
return nil, fmt.Errorf("write dek: %w", err)
}
if err := os.Rename(tmpKeyPath, keyPath); err != nil {
// If rename fails, another process might have created it. Try reading again.
existingKey, readErr := os.ReadFile(keyPath)
if readErr == nil && len(existingKey) == dekBytes {
return existingKey, nil
}
return nil, fmt.Errorf("save dek: %w", err)
}
return key, nil
}
func encryptData(plaintext string, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
+52 -7
View File
@@ -283,7 +283,19 @@ type CLIFlagOverride struct {
// name and Alias, and reserved names ("json", "params") are skipped.
// When any alias is set by the user, the binding's Required check is
// satisfied and the value is written to params[Property].
Aliases []string `json:"aliases,omitempty"`
Aliases []string `json:"aliases,omitempty"`
// MapsTo redirects this flag's final value into a different MCP parameter
// slot. When empty (default), the value is written to params[propertyName]
// as today. When set, params[MapsTo] receives the (possibly transformed)
// value and params[propertyName] is NOT written. This is what lets a
// sibling CLI flag — e.g. --content-file with transform: file_read — feed
// the same MCP parameter (markdown) as the existing literal --content
// flag, without forcing one flag to do double-duty.
//
// Pair with the existing CLIToolOverride.MutuallyExclusive (tool-level
// cobra MarkFlagsMutuallyExclusive) when two sibling flags map to the
// same MCP slot but should not be set together.
MapsTo string `json:"mapsTo,omitempty"`
Transform string `json:"transform,omitempty"`
TransformArgs map[string]any `json:"transformArgs,omitempty"`
EnvDefault string `json:"envDefault,omitempty"`
@@ -635,18 +647,24 @@ func NormalizeServers(response ListResponse, source string) []ServerDescriptor {
descriptor.UpdatedAt = updatedAt
}
existing, exists := bestByEndpoint[descriptor.Key]
// Dedup key includes cli.id when present so that envelopes
// intentionally splitting one MCP endpoint into multiple CLI command
// trees (e.g. bot-root / bot-message / bot-group all serving
// .../server/4717... but exposing different command roots) are not
// collapsed by endpoint-only dedup. Without cli.id the key falls
// back to descriptor.Key (= endpoint) for backwards compatibility.
endpointKey := dedupKeyForEndpoint(descriptor)
existing, exists := bestByEndpoint[endpointKey]
if !exists || descriptorIsNewer(descriptor, existing) {
bestByEndpoint[descriptor.Key] = descriptor
bestByEndpoint[endpointKey] = descriptor
}
}
bestByName := make(map[string]ServerDescriptor)
for _, descriptor := range bestByEndpoint {
nameKey := normalizeDisplayNameKey(descriptor.DisplayName)
if nameKey == "" {
nameKey = descriptor.Key
}
// Same reasoning as endpoint dedup: append cli.id so three bot-*
// entries with displayName "机器人消息" don't collapse into one.
nameKey := dedupKeyForName(descriptor)
existing, exists := bestByName[nameKey]
if !exists || descriptorIsNewer(descriptor, existing) {
bestByName[nameKey] = descriptor
@@ -692,6 +710,33 @@ func normalizeDisplayNameKey(displayName string) string {
return strings.ToLower(strings.TrimSpace(displayName))
}
// dedupKeyForEndpoint returns the dedup key used when collapsing multiple
// envelope entries that share an MCP endpoint. cli.id is appended so an
// endpoint intentionally fronting multiple CLI command roots (bot-root /
// bot-message / bot-group all served by the same MCP server) stays as
// distinct descriptors. When cli.id is empty (or absent), the key is the
// endpoint alone to preserve historical dedup behaviour.
func dedupKeyForEndpoint(descriptor ServerDescriptor) string {
if cliID := strings.TrimSpace(descriptor.CLI.ID); cliID != "" {
return descriptor.Key + "#" + cliID
}
return descriptor.Key
}
// dedupKeyForName mirrors dedupKeyForEndpoint for the second-pass name-based
// dedup so two envelopes with the same displayName but distinct cli.id (the
// bot-* trio shares displayName "机器人消息") remain separate.
func dedupKeyForName(descriptor ServerDescriptor) string {
nameKey := normalizeDisplayNameKey(descriptor.DisplayName)
if nameKey == "" {
nameKey = descriptor.Key
}
if cliID := strings.TrimSpace(descriptor.CLI.ID); cliID != "" {
return nameKey + "#" + cliID
}
return nameKey
}
func markDeprecatedCandidate(displayName string, lifecycle LifecycleInfo) LifecycleInfo {
if lifecycle.DeprecatedCandidate {
return lifecycle
+59
View File
@@ -101,6 +101,65 @@ func TestNormalizeServersDeduplicatesSameNameAcrossEndpoints(t *testing.T) {
}
}
// TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint guards the
// bot-root / bot-message / bot-group split (issue: chat bot subtree vanished
// from the CLI after NormalizeServers collapsed three envelopes that share a
// single MCP endpoint and displayName but expose different cli.id values).
func TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint(t *testing.T) {
t.Parallel()
sharedURL := "https://example.com/server/4717"
sharedName := "机器人消息"
response := ListResponse{
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: sharedName,
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
CLI: CLIOverlay{ID: "bot-root", Command: "bot"},
},
},
{
Server: RegistryServer{
Name: sharedName,
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
CLI: CLIOverlay{ID: "bot-message", Command: "message"},
},
},
{
Server: RegistryServer{
Name: sharedName,
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
CLI: CLIOverlay{ID: "bot-group", Command: "group"},
},
},
},
}
servers := NormalizeServers(response, "live_market")
if len(servers) != 3 {
t.Fatalf("NormalizeServers() len = %d, want 3 (one per cli.id)", len(servers))
}
seen := map[string]bool{}
for _, s := range servers {
seen[s.CLI.ID] = true
}
for _, want := range []string{"bot-root", "bot-message", "bot-group"} {
if !seen[want] {
t.Errorf("NormalizeServers() missing descriptor with cli.id %q", want)
}
}
}
func TestNormalizeServersMarksLegacyNameAsDeprecatedCandidate(t *testing.T) {
t.Parallel()
+158
View File
@@ -0,0 +1,158 @@
// 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 output
import (
"encoding/csv"
"io"
)
// writeCSV renders a payload as RFC-4180 CSV.
//
// It mirrors the shape decisions `-f table` already makes (same helpers:
// normalizePayload / unwrapPrimaryObject / extractRowsFromMap / rowsFromSlice /
// formatValue), so column order and value flattening stay consistent between
// the two formats:
//
// - a list of objects — either a bare [{...},...] or wrapped under a
// well-known key ({items|results|data|records|...}) — becomes a header row
// plus one row per element. The union of keys (sorted) is the column set;
// missing values are empty cells; nested objects/arrays render as compact
// JSON in the cell. Any sibling metadata of the list (total, hasMore, ...)
// is broadcast as extra trailing columns, repeated on every row, so a CSV
// consumer never loses it (CSV has no "two tables in one file" concept the
// way the table renderer's footer does). Meta keys that collide with a data
// column are skipped. An empty list still emits the header plus one row of
// empty data cells carrying just the meta values.
// - a single object becomes a two-column `key,value` CSV.
// - a non-uniform list or a scalar becomes a single-column `value` CSV.
//
// `--fields` projection composes for free: WriteFiltered applies SelectFields
// before Write reaches us, so the rows are already narrowed.
//
// encoding/csv.Writer handles quoting/escaping of commas, double quotes and
// embedded newlines; cell text goes through formatValue (which also strips
// terminal control sequences, same as the table renderer).
func writeCSV(w io.Writer, payload any) error {
normalized, err := normalizePayload(payload)
if err != nil {
return err
}
cw := csv.NewWriter(w)
switch typed := normalized.(type) {
case map[string]any:
// Try table extraction first so wrappers around list payloads
// (e.g. {result: {todoCards: [...]}}) render as a real table
// instead of being peeled by unwrapPrimaryObject and degraded
// to key/value rows. unwrapPrimaryObject is then the fallback
// for single-object wrappers like {invocation: {...}}.
if headers, rows, meta, ok := extractRowsFromMap(typed); ok {
headers, rows = broadcastMeta(headers, rows, meta)
return writeTableCSV(cw, headers, rows)
}
if inner, ok := unwrapPrimaryObject(typed); ok {
return writeKeyValueCSV(cw, inner)
}
return writeKeyValueCSV(cw, typed)
case []any:
headers, rows, _ := rowsFromSlice(typed)
return writeTableCSV(cw, headers, rows)
case nil:
// Nothing to write — emit an empty document rather than erroring.
cw.Flush()
return cw.Error()
default:
// Scalar: a single-cell, single-row CSV.
if err := cw.Write([]string{formatValue(normalized)}); err != nil {
return err
}
cw.Flush()
return cw.Error()
}
}
func writeTableCSV(cw *csv.Writer, headers []string, rows [][]string) error {
if err := cw.Write(headers); err != nil {
return err
}
for _, row := range rows {
// rowsFromSlice / extractRowsFromMap already guarantee
// len(row) == len(headers), but stay defensive against future callers.
if len(row) != len(headers) {
padded := make([]string, len(headers))
copy(padded, row)
row = padded
}
if err := cw.Write(row); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
}
// broadcastMeta appends the list's sibling metadata (total, hasMore, ...) as
// trailing columns repeated on every row. Meta keys that collide with an
// existing data column are skipped. If there are no rows but there is meta, a
// single row of empty data cells is emitted so the meta values aren't lost.
func broadcastMeta(headers []string, rows [][]string, meta map[string]any) ([]string, [][]string) {
if len(meta) == 0 {
return headers, rows
}
existing := make(map[string]bool, len(headers))
for _, h := range headers {
existing[h] = true
}
var metaKeys []string
var metaVals []string
for _, k := range sortedMapKeys(meta) {
if existing[k] {
continue
}
metaKeys = append(metaKeys, k)
metaVals = append(metaVals, formatValue(meta[k]))
}
if len(metaKeys) == 0 {
return headers, rows
}
outHeaders := append(append([]string{}, headers...), metaKeys...)
if len(rows) == 0 {
emptyData := make([]string, len(headers))
return outHeaders, [][]string{append(emptyData, metaVals...)}
}
outRows := make([][]string, len(rows))
for i, r := range rows {
nr := make([]string, 0, len(outHeaders))
nr = append(nr, r...)
nr = append(nr, metaVals...)
outRows[i] = nr
}
return outHeaders, outRows
}
func writeKeyValueCSV(cw *csv.Writer, m map[string]any) error {
if err := cw.Write([]string{"key", "value"}); err != nil {
return err
}
for _, key := range sortedMapKeys(m) {
if err := cw.Write([]string{key, formatValue(m[key])}); err != nil {
return err
}
}
cw.Flush()
return cw.Error()
}
+26 -14
View File
@@ -82,16 +82,21 @@ type dataListLocation struct {
// findDataList walks the object tree looking for the first array of
// objects under well-known keys. It searches both top-level and one
// level deep (e.g. result.value, response.items).
// level deep (e.g. result.value, response.items). The allow-list lives
// in preferredListKeys (formatter.go) and is shared with the table /
// csv renderers so all tabular formatters agree on what counts as the
// data list.
func findDataList(m map[string]any) *dataListLocation {
listKeys := []string{"value", "items", "results", "data", "list", "records", "tools", "servers", "products"}
// Top-level: {value: [...]}
for _, key := range listKeys {
if arr, ok := m[key].([]any); ok && len(arr) > 0 {
if _, isMap := arr[0].(map[string]any); isMap {
return &dataListLocation{list: arr, innerKey: key}
}
// Top-level: {value: [...]}. Empty arrays under a preferred key still
// match so an "empty list + metadata" payload renders as an empty table
// (with the meta broadcast) rather than degrading to key/value rows.
for _, key := range preferredListKeys {
arr, ok := m[key].([]any)
if !ok {
continue
}
if len(arr) == 0 || isMapValue(arr[0]) {
return &dataListLocation{list: arr, innerKey: key}
}
}
@@ -101,11 +106,13 @@ func findDataList(m map[string]any) *dataListLocation {
if !ok {
continue
}
for _, key := range listKeys {
if arr, ok := inner[key].([]any); ok && len(arr) > 0 {
if _, isMap := arr[0].(map[string]any); isMap {
return &dataListLocation{list: arr, outerKey: outerKey, innerKey: key}
}
for _, key := range preferredListKeys {
arr, ok := inner[key].([]any)
if !ok {
continue
}
if len(arr) == 0 || isMapValue(arr[0]) {
return &dataListLocation{list: arr, outerKey: outerKey, innerKey: key}
}
}
}
@@ -113,6 +120,11 @@ func findDataList(m map[string]any) *dataListLocation {
return nil
}
func isMapValue(v any) bool {
_, ok := v.(map[string]any)
return ok
}
// filterSlice applies field filtering to each object element in a
// slice. Non-object elements are passed through unchanged.
func filterSlice(items []any, wanted map[string]bool) []any {
+75 -23
View File
@@ -33,9 +33,26 @@ const (
FormatTable Format = "table"
FormatRaw Format = "raw"
FormatPretty Format = "pretty"
// FormatNDJSON emits one JSON object per line — friendly for streaming /
// piping list results into downstream tools. See ndjson.go.
FormatNDJSON Format = "ndjson"
// FormatCSV emits RFC-4180 comma-separated values for list-shaped results —
// friendly for spreadsheets and non-technical consumers. See csv.go.
FormatCSV Format = "csv"
)
var preferredListKeys = []string{"items", "results", "data", "list", "records", "tools", "servers", "products"}
// preferredListKeys is the shared allow-list of keys whose array values are
// treated as the "data list" by all tabular formatters (-f table / csv /
// ndjson). It is the single source of truth — findDataList in filter.go
// reuses it. When adding a new key, prefer real envelope keys observed in
// production responses over speculative future names.
var preferredListKeys = []string{
// Generic well-known list keys.
"value", "items", "results", "data", "list", "records",
"tools", "servers", "products",
// Envelope keys observed in real DingTalk responses.
"result", "documents", "emailAccounts", "todoCards", "events", "messages",
}
func ResolveFormat(cmd *cobra.Command, fallback Format) Format {
if cmd == nil {
@@ -78,6 +95,10 @@ func Write(w io.Writer, format Format, payload any) error {
return writeTableish(w, payload)
case FormatPretty:
return writePretty(w, payload)
case FormatNDJSON:
return writeNDJSON(w, payload)
case FormatCSV:
return writeCSV(w, payload)
default:
return WriteJSON(w, payload)
}
@@ -128,6 +149,10 @@ func normalizeFormat(raw string, fallback Format) Format {
return FormatTable
case string(FormatPretty):
return FormatPretty
case string(FormatNDJSON):
return FormatNDJSON
case string(FormatCSV):
return FormatCSV
default:
return fallback
}
@@ -261,9 +286,11 @@ func writeTableish(w io.Writer, payload any) error {
switch typed := normalized.(type) {
case map[string]any:
if inner, ok := unwrapPrimaryObject(typed); ok {
return writeKeyValues(w, inner)
}
// Try table extraction first so wrappers around list payloads
// (e.g. {result: {todoCards: [...]}}) render as a table instead
// of being peeled by unwrapPrimaryObject and degraded to key/
// value rows. unwrapPrimaryObject remains the fallback for
// single-object wrappers like {invocation: {kind, params, ...}}.
if headers, rows, meta, ok := extractRowsFromMap(typed); ok {
if err := writeTable(w, headers, rows); err != nil {
return err
@@ -276,6 +303,9 @@ func writeTableish(w io.Writer, payload any) error {
}
return nil
}
if inner, ok := unwrapPrimaryObject(typed); ok {
return writeKeyValues(w, inner)
}
return writeKeyValues(w, typed)
case []any:
if headers, rows, ok := rowsFromSlice(typed); ok {
@@ -322,30 +352,52 @@ func unwrapPrimaryObject(payload map[string]any) (map[string]any, bool) {
return nil, false
}
// extractRowsFromMap finds the data list inside a wrapper map and returns it
// as (headers, rows, meta). It delegates the search to findDataList so the
// detection rules stay aligned with -f ndjson: top-level under a preferred
// key, or one level deep under {result|response|data}. Meta is built from
// every sibling of the list — at both the outer and inner level when the
// list sits one level deep — so callers like the table renderer's footer and
// the csv broadcastMeta path see the same key set.
func extractRowsFromMap(payload map[string]any) ([]string, [][]string, map[string]any, bool) {
for _, key := range preferredListKeys {
value, ok := payload[key]
if !ok {
continue
}
list, ok := value.([]any)
if !ok {
continue
}
headers, rows, ok := rowsFromSlice(list)
if !ok {
continue
}
meta := make(map[string]any, len(payload)-1)
for metaKey, metaValue := range payload {
if metaKey == key {
loc := findDataList(payload)
if loc == nil {
return nil, nil, nil, false
}
headers, rows, ok := rowsFromSlice(loc.list)
if !ok {
return nil, nil, nil, false
}
meta := make(map[string]any)
if loc.outerKey == "" {
for k, v := range payload {
if k == loc.innerKey {
continue
}
meta[metaKey] = metaValue
meta[k] = v
}
} else {
for k, v := range payload {
if k == loc.outerKey {
continue
}
meta[k] = v
}
if inner, ok := payload[loc.outerKey].(map[string]any); ok {
for k, v := range inner {
if k == loc.innerKey {
continue
}
if _, exists := meta[k]; exists {
// Outer wins on key collision so users see the wrapper-level
// sibling rather than a clobbered inner one.
continue
}
meta[k] = v
}
}
return headers, rows, meta, true
}
return nil, nil, nil, false
return headers, rows, meta, true
}
func rowsFromSlice(items []any) ([]string, [][]string, bool) {
+86
View File
@@ -0,0 +1,86 @@
// 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 output
import (
"bufio"
"encoding/json"
"io"
)
// writeNDJSON renders payload as newline-delimited JSON (https://ndjson.org):
// - a top-level array → one element per line
// - an object that wraps a → one element of that list per line
// well-known list key (items / results / data / records / value / ...)
// - anything else → a single line containing the whole value
//
// This is the streaming-friendly counterpart to `-f json`: each line is an
// independent, compact JSON document so consumers can `jq -c`, `while read`,
// or pipe into log pipelines without buffering the whole response.
//
// TODO(#252): consider honouring --fields per-line projection here too (today
// WriteFiltered already applies SelectFields before Write is reached, so this
// works, but a dedicated test would be good). Also decide whether non-list
// payloads should error under `-f ndjson` instead of degrading to one line.
func writeNDJSON(w io.Writer, payload any) error {
normalized, err := roundTripJSON(payload)
if err != nil {
return err
}
bw := bufio.NewWriter(w)
enc := json.NewEncoder(bw)
// json.Encoder.Encode already appends a trailing newline per call.
switch v := normalized.(type) {
case []any:
for _, item := range v {
if err := enc.Encode(item); err != nil {
return err
}
}
case map[string]any:
if loc := findDataList(v); loc != nil {
for _, item := range loc.list {
if err := enc.Encode(item); err != nil {
return err
}
}
} else {
if err := enc.Encode(v); err != nil {
return err
}
}
default:
if err := enc.Encode(v); err != nil {
return err
}
}
return bw.Flush()
}
// roundTripJSON normalizes an arbitrary Go value into the
// map[string]any / []any / scalar shape used by the rest of this package by
// marshalling and unmarshalling it through encoding/json.
func roundTripJSON(payload any) (any, error) {
raw, err := json.Marshal(payload)
if err != nil {
return nil, err
}
var out any
if err := json.Unmarshal(raw, &out); err != nil {
return nil, err
}
return out, nil
}
+235
View File
@@ -0,0 +1,235 @@
// 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 output
import (
"bytes"
"strings"
"testing"
)
func TestNormalizeFormatRecognizesNDJSONAndCSV(t *testing.T) {
if got := normalizeFormat("ndjson", FormatJSON); got != FormatNDJSON {
t.Errorf("normalizeFormat(ndjson) = %q, want %q", got, FormatNDJSON)
}
if got := normalizeFormat("CSV", FormatJSON); got != FormatCSV {
t.Errorf("normalizeFormat(CSV) = %q, want %q", got, FormatCSV)
}
}
func TestWriteNDJSON(t *testing.T) {
cases := []struct {
name string
payload any
wantLines []string
}{
{
name: "top-level array",
payload: []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}},
wantLines: []string{`{"id":"1"}`, `{"id":"2"}`},
},
{
name: "wrapped list",
payload: map[string]any{"items": []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}}, "count": 2},
wantLines: []string{`{"id":"1"}`, `{"id":"2"}`},
},
{
name: "scalar-ish object",
payload: map[string]any{"ok": true},
wantLines: []string{`{"ok":true}`},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
if err := Write(&buf, FormatNDJSON, tc.payload); err != nil {
t.Fatalf("Write(ndjson) error = %v", err)
}
got := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
if len(got) != len(tc.wantLines) {
t.Fatalf("got %d lines %q, want %d %q", len(got), got, len(tc.wantLines), tc.wantLines)
}
for i, want := range tc.wantLines {
if strings.TrimSpace(got[i]) != want {
t.Errorf("line %d = %q, want %q", i, got[i], want)
}
}
})
}
}
func TestWriteCSV(t *testing.T) {
cases := []struct {
name string
payload any
want string
}{
{
// Union of keys (sorted), missing values → empty cells, a field with
// a comma gets quoted, CJK passes through verbatim, a nested array is
// rendered as compact JSON with its quotes CSV-escaped.
name: "list of objects",
payload: []any{
map[string]any{"id": "1", "name": "张三"},
map[string]any{"id": "2", "name": "Bob, Jr."},
map[string]any{"id": "3", "tags": []any{"x", "y"}},
},
want: "id,name,tags\n" +
"1,张三,\n" +
"2,\"Bob, Jr.\",\n" +
"3,,\"[\"\"x\"\",\"\"y\"\"]\"\n",
},
{
// {records:[...], total:N}: the list becomes the table; sibling
// metadata (total) is broadcast as a trailing column on every row.
name: "wrapped list with metadata",
payload: map[string]any{
"records": []any{map[string]any{"id": "1"}, map[string]any{"id": "2"}},
"total": 2,
},
want: "id,total\n1,2\n2,2\n",
},
{
// Empty list + metadata: still emit the header (data + meta) plus a
// single row of empty data cells carrying the meta values.
name: "empty wrapped list with metadata",
payload: map[string]any{
"records": []any{},
"total": 0,
"hasMore": false,
},
want: "value,hasMore,total\n,false,0\n",
},
{
// A plain object → two-column key,value CSV with keys sorted.
name: "single object",
payload: map[string]any{"ok": true, "name": "x"},
want: "key,value\nname,x\nok,true\n",
},
{
name: "scalar",
payload: "hello",
want: "hello\n",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var buf bytes.Buffer
if err := Write(&buf, FormatCSV, tc.payload); err != nil {
t.Fatalf("Write(csv) error = %v", err)
}
if got := buf.String(); got != tc.want {
t.Errorf("Write(csv) =\n%q\nwant\n%q", got, tc.want)
}
})
}
}
// TestWriteCSVComposesWithFields guards that --fields projection (applied by
// WriteFiltered before Write) narrows the CSV columns.
func TestWriteCSVComposesWithFields(t *testing.T) {
payload := map[string]any{
"items": []any{
map[string]any{"id": "1", "name": "Alice", "secret": "s1"},
map[string]any{"id": "2", "name": "Bob", "secret": "s2"},
},
}
var buf bytes.Buffer
if err := WriteFiltered(&buf, FormatCSV, payload, "id,name", ""); err != nil {
t.Fatalf("WriteFiltered(csv) error = %v", err)
}
got := buf.String()
if strings.Contains(got, "secret") || strings.Contains(got, "s1") {
t.Errorf("--fields did not drop the secret column; got:\n%s", got)
}
if !strings.Contains(got, "id,name") || !strings.Contains(got, "Alice") {
t.Errorf("expected projected columns id,name with values; got:\n%s", got)
}
}
// TestTabularDetectsRealDingTalkEnvelopes guards against shipping a -f csv /
// -f ndjson that degrades to one-line-key-value for the envelope shapes the
// real product surface actually returns. Each case is a payload shape observed
// in production (contact / doc / mail / todo / chat search responses).
func TestTabularDetectsRealDingTalkEnvelopes(t *testing.T) {
cases := []struct {
name string
payload map[string]any
wantNDLines int // expected line count from -f ndjson
wantCSVHead string // first header line of -f csv
}{
{
name: "result direct array (contact user search)",
payload: map[string]any{
"result": []any{map[string]any{"name": "张三", "userId": "123"}, map[string]any{"name": "李四", "userId": "456"}},
"success": true,
},
wantNDLines: 2,
wantCSVHead: "name,userId,success",
},
{
name: "documents top-level (doc search)",
payload: map[string]any{
"documents": []any{map[string]any{"nodeId": "n1", "name": "A"}, map[string]any{"nodeId": "n2", "name": "B"}},
"hasMore": true,
"nextPageToken": "tok",
},
wantNDLines: 2,
wantCSVHead: "name,nodeId,hasMore,nextPageToken",
},
{
name: "emailAccounts top-level (mail mailbox list)",
payload: map[string]any{
"emailAccounts": []any{map[string]any{"email": "a@b.com", "type": "ORG"}},
"success": "true",
},
wantNDLines: 1,
wantCSVHead: "email,type,success",
},
{
name: "todoCards under result wrapper (todo task list)",
payload: map[string]any{
"result": map[string]any{
"todoCards": []any{
map[string]any{"taskId": "t1", "subject": "做一做"},
map[string]any{"taskId": "t2", "subject": "再做一做"},
},
},
},
wantNDLines: 2,
wantCSVHead: "subject,taskId",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var nd bytes.Buffer
if err := Write(&nd, FormatNDJSON, tc.payload); err != nil {
t.Fatalf("ndjson write: %v", err)
}
ndLines := strings.Split(strings.TrimRight(nd.String(), "\n"), "\n")
if len(ndLines) != tc.wantNDLines {
t.Errorf("ndjson: got %d lines %q, want %d", len(ndLines), ndLines, tc.wantNDLines)
}
var c bytes.Buffer
if err := Write(&c, FormatCSV, tc.payload); err != nil {
t.Fatalf("csv write: %v", err)
}
gotHead := strings.SplitN(c.String(), "\n", 2)[0]
if gotHead != tc.wantCSVHead {
t.Errorf("csv header: got %q, want %q", gotHead, tc.wantCSVHead)
}
})
}
}
+25 -10
View File
@@ -79,6 +79,12 @@ func RunPreParse(root *cobra.Command, engine *Engine) {
// FlagInfoFromCommand extracts FlagInfo entries from a Cobra
// command's registered flags (both local and inherited).
//
// JSON Schema "format" and "enum" hints injected via pflag
// annotations (x-cli-format / x-cli-enum, see
// internal/compat/dynamic_commands.go) are surfaced on FlagInfo
// so PreParse handlers can validate sticky-split candidates against
// the actual schema, not just the pflag type.
func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
if cmd == nil {
return nil
@@ -92,11 +98,7 @@ func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
return
}
seen[f.Name] = true
infos = append(infos, FlagInfo{
Name: f.Name,
PropertyName: f.Name,
Type: f.Value.Type(),
})
infos = append(infos, flagInfoFromPflag(f))
})
cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) {
@@ -104,12 +106,25 @@ func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo {
return
}
seen[f.Name] = true
infos = append(infos, FlagInfo{
Name: f.Name,
PropertyName: f.Name,
Type: f.Value.Type(),
})
infos = append(infos, flagInfoFromPflag(f))
})
return infos
}
// flagInfoFromPflag builds a FlagInfo from a pflag.Flag, copying
// schema metadata stashed in the flag's annotations map.
func flagInfoFromPflag(f *pflag.Flag) FlagInfo {
fi := FlagInfo{
Name: f.Name,
PropertyName: f.Name,
Type: f.Value.Type(),
}
if v := f.Annotations["x-cli-format"]; len(v) > 0 {
fi.Format = v[0]
}
if v := f.Annotations["x-cli-enum"]; len(v) > 0 {
fi.Enum = append([]string{}, v...)
}
return fi
}
+45 -12
View File
@@ -32,76 +32,101 @@ func TestFullPreParsePipeline(t *testing.T) {
ParamNameHandler{},
)
// Numeric / boolean flag typing matters for the sticky guard. The
// helper below declares known flags as int so digit-led suffixes
// pass the suffixLooksLikeValue check.
intSpecs := func(names ...string) []pipeline.FlagInfo {
out := make([]pipeline.FlagInfo, len(names))
for i, n := range names {
out[i] = pipeline.FlagInfo{Name: n, Type: "int"}
}
return out
}
tests := []struct {
name string
args []string
flags []string
flags []pipeline.FlagInfo
want string
corrections int
}{
{
name: "camelCase + sticky combined",
args: []string{"--userId", "123", "--pageSize50"},
flags: []string{"user-id", "page-size"},
flags: intSpecs("user-id", "page-size"),
want: "--user-id 123 --page-size 50",
corrections: 2, // alias(userId) + sticky(pageSize50)
},
{
name: "camelCase + typo combined",
args: []string{"--userId", "123", "--limt", "10"},
flags: []string{"user-id", "limit"},
flags: intSpecs("user-id", "limit"),
want: "--user-id 123 --limit 10",
corrections: 2, // alias(userId) + fuzzy(limt)
},
{
name: "triple error: case + sticky + typo",
args: []string{"--UserName", "alice", "--limit100", "--offse", "0"},
flags: []string{"user-name", "limit", "offset"},
flags: append(flagSpecs("user-name"), intSpecs("limit", "offset")...),
want: "--user-name alice --limit 100 --offset 0",
corrections: 3,
},
{
name: "snake_case + sticky",
args: []string{"--user_id", "42", "--pageSize20"},
flags: []string{"user-id", "page-size"},
flags: intSpecs("user-id", "page-size"),
want: "--user-id 42 --page-size 20",
corrections: 2,
},
{
name: "all correct — zero corrections",
args: []string{"--user-id", "123", "--limit", "10"},
flags: []string{"user-id", "limit"},
flags: intSpecs("user-id", "limit"),
want: "--user-id 123 --limit 10",
corrections: 0,
},
{
name: "UPPER case flags",
args: []string{"--USER-ID", "999"},
flags: []string{"user-id"},
flags: intSpecs("user-id"),
want: "--user-id 999",
corrections: 1,
},
{
name: "= syntax with camelCase",
args: []string{"--userId=123", "--pageSize=50"},
flags: []string{"user-id", "page-size"},
flags: intSpecs("user-id", "page-size"),
want: "--user-id=123 --page-size=50",
corrections: 2,
},
{
name: "camelCase sticky split with normalisation",
args: []string{"--limitValue100"},
flags: []string{"limit-value"},
flags: intSpecs("limit-value"),
want: "--limit-value 100",
corrections: 1, // sticky handles both kebab-normalisation and split
},
// Hardening: a mistyped flag whose name happens to start with
// a real flag must NOT be split. The pipeline should leave the
// token untouched so Cobra can raise "unknown flag".
{
name: "typo --starttime1 not split (date-time format)",
args: []string{"--starttime1", "2026-02-07"},
flags: []pipeline.FlagInfo{
{Name: "start", Type: "string", Format: "date-time"},
{Name: "end", Type: "string", Format: "date-time"},
},
want: "--starttime1 2026-02-07",
corrections: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &pipeline.Context{
Args: append([]string{}, tt.args...),
FlagSpecs: flagSpecs(tt.flags...),
FlagSpecs: tt.flags,
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("RunPhase error: %v", err)
@@ -191,7 +216,11 @@ func TestFullPipelineEndToEnd(t *testing.T) {
"--pageSize50",
"--verbosetrue",
},
FlagSpecs: flagSpecs("user-id", "page-size", "verbose"),
FlagSpecs: []pipeline.FlagInfo{
{Name: "user-id", Type: "string"},
{Name: "page-size", Type: "int"},
{Name: "verbose", Type: "bool"},
},
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
@@ -287,7 +316,11 @@ func TestFullFivePhasePipeline(t *testing.T) {
"--pageSize50",
"--verbosetrue",
}
ctx.FlagSpecs = flagSpecs("user-id", "page-size", "verbose")
ctx.FlagSpecs = []pipeline.FlagInfo{
{Name: "user-id", Type: "string"},
{Name: "page-size", Type: "int"},
{Name: "verbose", Type: "bool"},
}
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
t.Fatalf("PreParse error: %v", err)
+45 -8
View File
@@ -17,6 +17,7 @@ import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
)
// StickyHandler detects glued flag-value pairs in raw argv and splits
@@ -25,7 +26,12 @@ import (
//
// The handler only operates on tokens that start with "--" and do not
// contain "=". It tries to match the longest known flag name prefix
// and, if the remaining suffix is non-empty, splits the token.
// and, if the remaining suffix is non-empty AND looks like a plausible
// value for the matched flag's type/format, splits the token. The
// type/format guard prevents misinterpreting mistyped flag names like
// "--starttime1" as "--start time1" — when the suffix does not look
// like a value, the original token is left untouched so Cobra can
// report "unknown flag".
type StickyHandler struct{}
func (StickyHandler) Name() string { return "sticky" }
@@ -36,11 +42,11 @@ func (StickyHandler) Handle(ctx *pipeline.Context) error {
return nil
}
known := buildFlagSet(ctx.FlagSpecs)
specByName := buildFlagSpecIndex(ctx.FlagSpecs)
result := make([]string, 0, len(ctx.Args))
for _, arg := range ctx.Args {
split, ok := trySplitSticky(arg, known)
split, ok := trySplitSticky(arg, specByName)
if ok {
ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky")
result = append(result, split.flag, split.value)
@@ -65,12 +71,14 @@ type stickyPair struct {
// - the prefix matches a known flag name (directly or after
// kebab-case normalisation)
// - the remaining suffix is non-empty
// - the suffix looks like a plausible value for the matched flag's
// declared type/format/enum (suffixLooksLikeValue)
//
// When multiple flag names match as prefixes, the longest one wins.
// The handler also tries kebab-case normalisation of each prefix so
// that camelCase+glued values like "--pageSize50" are correctly
// split to "--page-size", "50".
func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
func trySplitSticky(arg string, specByName map[string]pipeline.FlagInfo) (stickyPair, bool) {
if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") {
return stickyPair{}, false
}
@@ -83,7 +91,10 @@ func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
// If the whole token is a known flag, it is not sticky — it is
// a normal flag expecting a separate value token.
if known[bare] || known[toKebabCase(bare)] {
if _, ok := specByName[bare]; ok {
return stickyPair{}, false
}
if _, ok := specByName[toKebabCase(bare)]; ok {
return stickyPair{}, false
}
@@ -96,12 +107,14 @@ func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
prefix := bare[:i]
matchedFlag := ""
if known[prefix] {
if _, ok := specByName[prefix]; ok {
matchedFlag = prefix
} else {
kebab := toKebabCase(prefix)
if kebab != "" && known[kebab] {
matchedFlag = kebab
if kebab != "" {
if _, ok := specByName[kebab]; ok {
matchedFlag = kebab
}
}
}
@@ -120,14 +133,38 @@ func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) {
return stickyPair{}, false
}
// Guard: only split if the suffix plausibly looks like a value
// for this flag's declared type/format/enum. Otherwise leave the
// token untouched so Cobra reports "unknown flag" instead of
// silently corrupting the value.
fi := specByName[bestFlag]
if !cmdutil.SuffixLooksLikeValue(suffix, fi.Type, fi.Format, fi.Enum) {
return stickyPair{}, false
}
return stickyPair{
flag: "--" + bestFlag,
value: suffix,
}, true
}
// buildFlagSpecIndex creates an index of known flag names (without "--"
// prefix) to their FlagInfo entries from the context's FlagSpecs.
func buildFlagSpecIndex(specs []pipeline.FlagInfo) map[string]pipeline.FlagInfo {
m := make(map[string]pipeline.FlagInfo, len(specs))
for _, spec := range specs {
if spec.Name != "" {
m[spec.Name] = spec
}
}
return m
}
// buildFlagSet creates a set of known flag names (without "--" prefix)
// from the context's FlagSpecs.
//
// Retained for other PreParse handlers (alias, paramname) that only
// need name presence and do not consume the richer FlagInfo metadata.
func buildFlagSet(specs []pipeline.FlagInfo) map[string]bool {
m := make(map[string]bool, len(specs))
for _, spec := range specs {
+161 -27
View File
@@ -20,6 +20,12 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
)
// flagSpecs is the legacy helper used by alias/paramname tests. It
// produces string-typed FlagInfo entries which is fine for those
// handlers because they don't consult the type.
//
// Sticky tests should use flagSpecsTyped (below) — the post-hardening
// sticky decision depends on Type/Format/Enum.
func flagSpecs(names ...string) []pipeline.FlagInfo {
specs := make([]pipeline.FlagInfo, len(names))
for i, name := range names {
@@ -28,126 +34,254 @@ func flagSpecs(names ...string) []pipeline.FlagInfo {
return specs
}
// flagSpec is a compact builder used by sticky tests to declare typed
// flags inline. Type defaults to "string" when unset.
type flagSpec struct {
name string
typ string
format string
enum []string
}
func specs(in ...flagSpec) []pipeline.FlagInfo {
out := make([]pipeline.FlagInfo, len(in))
for i, s := range in {
t := s.typ
if t == "" {
t = "string"
}
out[i] = pipeline.FlagInfo{
Name: s.name,
Type: t,
Format: s.format,
Enum: s.enum,
}
}
return out
}
func TestStickyHandler(t *testing.T) {
tests := []struct {
name string
args []string
flags []string
flags []pipeline.FlagInfo
want string
corrections int
}{
{
name: "basic split --limit100",
name: "basic split --limit100 (int)",
args: []string{"--limit100"},
flags: []string{"limit"},
flags: specs(flagSpec{name: "limit", typ: "int"}),
want: "--limit 100",
corrections: 1,
},
{
name: "no split when flag takes value separately",
args: []string{"--limit", "100"},
flags: []string{"limit"},
flags: specs(flagSpec{name: "limit", typ: "int"}),
want: "--limit 100",
corrections: 0,
},
{
name: "no split when = syntax used",
args: []string{"--limit=100"},
flags: []string{"limit"},
flags: specs(flagSpec{name: "limit", typ: "int"}),
want: "--limit=100",
corrections: 0,
},
{
name: "no split when flag name is not known",
args: []string{"--unknown100"},
flags: []string{"limit"},
flags: specs(flagSpec{name: "limit", typ: "int"}),
want: "--unknown100",
corrections: 0,
},
{
name: "split with string value",
args: []string{"--nameJohn"},
flags: []string{"name"},
want: "--name John",
corrections: 1,
},
{
name: "longest prefix wins",
args: []string{"--user-id123"},
flags: []string{"user", "user-id"},
flags: specs(flagSpec{name: "user"}, flagSpec{name: "user-id", typ: "int"}),
want: "--user-id 123",
corrections: 1,
},
{
name: "multiple sticky args in one invocation",
args: []string{"--limit100", "--offset50"},
flags: []string{"limit", "offset"},
flags: specs(flagSpec{name: "limit", typ: "int"}, flagSpec{name: "offset", typ: "int"}),
want: "--limit 100 --offset 50",
corrections: 2,
},
{
name: "mixed sticky and normal args",
args: []string{"--limit100", "--name", "test", "--offset50"},
flags: []string{"limit", "name", "offset"},
name: "mixed sticky and normal args",
args: []string{"--limit100", "--name", "test", "--offset50"},
flags: specs(
flagSpec{name: "limit", typ: "int"},
flagSpec{name: "name"},
flagSpec{name: "offset", typ: "int"},
),
want: "--limit 100 --name test --offset 50",
corrections: 2,
},
{
name: "single dash prefix is ignored",
args: []string{"-l100"},
flags: []string{"l"},
flags: specs(flagSpec{name: "l", typ: "int"}),
want: "-l100",
corrections: 0,
},
{
name: "empty args",
args: []string{},
flags: []string{"limit"},
flags: specs(flagSpec{name: "limit", typ: "int"}),
want: "",
corrections: 0,
},
{
name: "bare double dash",
args: []string{"--"},
flags: []string{"limit"},
flags: specs(flagSpec{name: "limit", typ: "int"}),
want: "--",
corrections: 0,
},
{
name: "no flag specs available",
args: []string{"--limit100"},
flags: []string{},
flags: nil,
want: "--limit100",
corrections: 0,
},
{
name: "exact flag name is not split",
args: []string{"--limit"},
flags: []string{"limit"},
flags: specs(flagSpec{name: "limit", typ: "int"}),
want: "--limit",
corrections: 0,
},
{
name: "boolean-like value still splits",
name: "boolean-like value splits when type is bool",
args: []string{"--verbosetrue"},
flags: []string{"verbose"},
flags: specs(flagSpec{name: "verbose", typ: "bool"}),
want: "--verbose true",
corrections: 1,
},
{
name: "hyphenated flag name with numeric suffix",
args: []string{"--page-size50"},
flags: []string{"page-size", "page"},
flags: specs(flagSpec{name: "page-size", typ: "int"}, flagSpec{name: "page", typ: "int"}),
want: "--page-size 50",
corrections: 1,
},
// --- Hardening cases: typo flags MUST NOT be misinterpreted ---
{
name: "typo --starttime1 not split (date-time format)",
args: []string{"--starttime1", "2026-02-07"},
flags: specs(flagSpec{
name: "start", typ: "string", format: "date-time",
}),
want: "--starttime1 2026-02-07",
corrections: 0,
},
{
name: "glued ISO date splits cleanly (date-time format)",
args: []string{"--start2026-02-07"},
flags: specs(flagSpec{
name: "start", typ: "string", format: "date-time",
}),
want: "--start 2026-02-07",
corrections: 1,
},
{
name: "int flag rejects alpha suffix",
args: []string{"--limitabc"},
flags: specs(flagSpec{name: "limit", typ: "int"}),
want: "--limitabc",
corrections: 0,
},
{
name: "bool flag rejects non-literal suffix",
args: []string{"--verbosehello"},
flags: specs(flagSpec{name: "verbose", typ: "bool"}),
want: "--verbosehello",
corrections: 0,
},
{
name: "string + enum: suffix hits enum splits",
args: []string{"--statusapproved"},
flags: specs(flagSpec{
name: "status", typ: "string", enum: []string{"approved", "pending"},
}),
want: "--status approved",
corrections: 1,
},
{
name: "string + enum: suffix not in enum refuses split",
args: []string{"--statusunknown"},
flags: specs(flagSpec{
name: "status", typ: "string", enum: []string{"approved", "pending"},
}),
want: "--statusunknown",
corrections: 0,
},
{
name: "email format: suffix containing @ splits",
args: []string{"--emailfoo@bar.com"},
flags: specs(flagSpec{
name: "email", typ: "string", format: "email",
}),
want: "--email foo@bar.com",
corrections: 1,
},
{
name: "email format: suffix without @ refuses split",
args: []string{"--emailalice"},
flags: specs(flagSpec{
name: "email", typ: "string", format: "email",
}),
want: "--emailalice",
corrections: 0,
},
{
name: "stringSlice: refuses to split (ambiguous)",
args: []string{"--tagsfoo,bar"},
flags: specs(flagSpec{name: "tags", typ: "stringSlice"}),
want: "--tagsfoo,bar",
corrections: 0,
},
{
name: "plain string + no metadata: alpha suffix refuses split",
args: []string{"--nameJohn"},
flags: specs(flagSpec{name: "name"}),
want: "--nameJohn",
corrections: 0,
},
{
name: "plain string + no metadata: digit-led suffix splits",
args: []string{"--name123"},
flags: specs(flagSpec{name: "name"}),
want: "--name 123",
corrections: 1,
},
{
name: "duration type: digit-led suffix splits",
args: []string{"--timeout30s"},
flags: specs(flagSpec{name: "timeout", typ: "duration"}),
want: "--timeout 30s",
corrections: 1,
},
{
name: "duration type: alpha suffix refuses split",
args: []string{"--timeoutever"},
flags: specs(flagSpec{name: "timeout", typ: "duration"}),
want: "--timeoutever",
corrections: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := &pipeline.Context{
Args: append([]string{}, tt.args...),
FlagSpecs: flagSpecs(tt.flags...),
FlagSpecs: tt.flags,
}
h := StickyHandler{}
if err := h.Handle(ctx); err != nil {
+13 -1
View File
@@ -120,8 +120,20 @@ type FlagInfo struct {
// PropertyName is the original schema property key (e.g. "userId").
PropertyName string
// Type is the JSON Schema type ("string", "integer", etc.).
// Type is the JSON Schema / pflag type ("string", "integer",
// "bool", "stringSlice", "duration", etc.).
Type string
// Format carries the JSON Schema "format" hint when present —
// e.g. "date", "date-time", "duration", "email", "uri", "ipv4".
// PreParse handlers use this to decide whether a suffix in a
// glued token (e.g. "--starttime1") looks like a plausible value.
Format string
// Enum carries the JSON Schema "enum" string values when present.
// PreParse handlers use this for sticky-split validation: a glued
// suffix is accepted only if it matches one of the enum entries.
Enum []string
}
// Correction records a single input correction applied by a handler.
+4
View File
@@ -106,6 +106,7 @@ func (s *StdioClient) Start(ctx context.Context) error {
}
// Stop kills the subprocess and waits for it to exit.
// A non-zero exit code after Kill is expected and not treated as an error.
func (s *StdioClient) Stop() error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -118,6 +119,9 @@ func (s *StdioClient) Stop() error {
if s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
_ = s.cmd.Wait()
s.started = false
return nil
}
err := s.cmd.Wait()
s.started = false
+2 -3
View File
@@ -97,10 +97,9 @@ func TestStdioClientEndToEnd(t *testing.T) {
t.Error("CallTool with unknown tool should return error")
}
// Stop
// Stop — after kill, Stop should return nil (non-zero exit is suppressed)
if err := client.Stop(); err != nil {
// Process killed, expected to return an error
_ = err
t.Errorf("Stop after kill should return nil, got %v", err)
}
}
+78 -6
View File
@@ -15,6 +15,7 @@ package cmdutil
import (
"fmt"
"sort"
"strings"
"github.com/spf13/cobra"
@@ -75,6 +76,41 @@ func DetectNumericTypeError(err error) (flagName, badValue string, ok bool) {
return rest[:endIdx], badVal, true
}
// flagFixCandidate reports whether f should participate in unknown-flag
// suggestion candidates and Flags: listings. Hidden flags (e.g. wukong's
// MarkHidden compatibility aliases) and internal json/params merge flags
// are skipped so the hint candidate set stays a subset of what --help shows.
func flagFixCandidate(f *pflag.Flag) bool {
if f == nil || f.Hidden {
return false
}
switch f.Name {
case "json", "params":
return false
}
return true
}
// VisibleFlagNames returns sorted candidate flag names for cmd.Flags()
// using flagFixCandidate. Intended for agent-facing error recovery
// (available_flags).
func VisibleFlagNames(cmd *cobra.Command) []string {
if cmd == nil {
return nil
}
seen := make(map[string]bool)
var names []string
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if !flagFixCandidate(f) || seen[f.Name] {
return
}
seen[f.Name] = true
names = append(names, f.Name)
})
sort.Strings(names)
return names
}
// SuggestFlagFix detects flag-value concatenation errors, common flag aliases,
// and Levenshtein-close typos.
func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
@@ -92,6 +128,9 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
var bestFlag, bestValue string
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if !flagFixCandidate(f) {
return
}
name := f.Name
if strings.HasPrefix(body, name) && len(body) > len(name) {
if len(name) > len(bestFlag) {
@@ -101,16 +140,28 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
}
})
if bestFlag != "" {
canAutoFix := len(bestValue) > 0
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
if canAutoFix {
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
lf := cmd.Flags().Lookup(bestFlag)
if lf != nil {
fmtStr := ""
if v := lf.Annotations["x-cli-format"]; len(v) > 0 {
fmtStr = v[0]
}
var enumCopy []string
if v := lf.Annotations["x-cli-enum"]; len(v) > 0 {
enumCopy = append([]string{}, v...)
}
if SuffixLooksLikeValue(bestValue, lf.Value.Type(), fmtStr, enumCopy) {
suggestion := fmt.Sprintf("Space required between flag and value: --%s %s", bestFlag, bestValue)
return FlagFixResult{Suggestion: suggestion, AutoFixFlag: bestFlag, AutoFixValue: bestValue}
}
}
return FlagFixResult{Suggestion: suggestion}
}
bestName, bestDist := "", 999
cmd.Flags().VisitAll(func(f *pflag.Flag) {
if !flagFixCandidate(f) {
return
}
d := LevenshteinDist(body, f.Name)
if d < bestDist {
bestDist = d
@@ -119,12 +170,33 @@ func SuggestFlagFix(cmd *cobra.Command, flagErr error) FlagFixResult {
})
threshold := LevenshteinThreshold(len(body))
if bestDist > 0 && bestDist <= threshold && bestName != "" {
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?", bestName)}
suf := formatFlagHintSuffix(cmd.Flags().Lookup(bestName))
return FlagFixResult{Suggestion: fmt.Sprintf("Did you mean --%s?%s", bestName, suf)}
}
return FlagFixResult{Suggestion: fmt.Sprintf("Run '%s --help' to see available options", cmd.CommandPath())}
}
func formatFlagHintSuffix(f *pflag.Flag) string {
if f == nil {
return ""
}
var parts []string
if u := strings.TrimSpace(f.Usage); u != "" {
if len(u) > 100 {
u = u[:97] + "..."
}
parts = append(parts, u)
}
if v := f.Annotations["x-cli-format"]; len(v) > 0 && v[0] != "" {
parts = append(parts, "format="+v[0])
}
if len(parts) == 0 {
return ""
}
return " (" + strings.Join(parts, ", ") + ")"
}
// LevenshteinThreshold returns the max edit distance allowed based on string length.
func LevenshteinThreshold(nameLen int) int {
if nameLen <= 3 {
+106
View File
@@ -0,0 +1,106 @@
// 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 cmdutil
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
func TestSuggestFlagFix_falseGlue_starttime1(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
cmd.Flags().String("start", "", "begin time")
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
err := errUnknownFlag("starttime1")
fix := SuggestFlagFix(cmd, err)
if strings.Contains(fix.Suggestion, "Space required") {
t.Fatalf("should not treat as glued value, got %q", fix.Suggestion)
}
if fix.AutoFixFlag != "" {
t.Fatalf("AutoFixFlag = %q, want empty", fix.AutoFixFlag)
}
if !strings.Contains(fix.Suggestion, "--help") {
t.Fatalf("expected help fallback, got %q", fix.Suggestion)
}
}
func TestSuggestFlagFix_trueGlue_isoDateSuffix(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
cmd.Flags().String("start", "", "begin time")
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
err := errUnknownFlag("start2026-02-07")
fix := SuggestFlagFix(cmd, err)
wantSub := "Space required between flag and value: --start 2026-02-07"
if fix.Suggestion != wantSub {
t.Fatalf("Suggestion = %q, want %q", fix.Suggestion, wantSub)
}
if fix.AutoFixFlag != "start" || fix.AutoFixValue != "2026-02-07" {
t.Fatalf("AutoFix = %q/%q, want start/2026-02-07", fix.AutoFixFlag, fix.AutoFixValue)
}
}
func TestSuggestFlagFix_levenshteinAddsUsage(t *testing.T) {
cmd := &cobra.Command{Use: "send"}
cmd.Flags().String("conversation-id", "", "Conversation id")
err := errUnknownFlag("conversaton-id")
fix := SuggestFlagFix(cmd, err)
if !strings.HasPrefix(fix.Suggestion, "Did you mean --conversation-id?") {
t.Fatalf("unexpected: %q", fix.Suggestion)
}
if !strings.Contains(fix.Suggestion, "Conversation id") {
t.Fatalf("expected usage in hint, got %q", fix.Suggestion)
}
}
func TestSuggestFlagFix_skipsHiddenAlias(t *testing.T) {
cmd := &cobra.Command{Use: "list"}
cmd.Flags().String("start", "", "begin time")
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
cmd.Flags().String("start-time", "", "")
_ = cmd.Flags().MarkHidden("start-time")
fix := SuggestFlagFix(cmd, errUnknownFlag("starttime1"))
if strings.Contains(fix.Suggestion, "start-time") {
t.Fatalf("must not recommend hidden alias, got %q", fix.Suggestion)
}
if !strings.Contains(fix.Suggestion, "--help") {
t.Fatalf("expected help fallback, got %q", fix.Suggestion)
}
}
func TestVisibleFlagNames_skipsHiddenAndInternal(t *testing.T) {
cmd := &cobra.Command{Use: "x"}
cmd.Flags().String("alpha", "", "")
cmd.Flags().String("json", "", "")
cmd.Flags().String("beta", "", "")
_ = cmd.Flags().MarkHidden("beta")
names := VisibleFlagNames(cmd)
if len(names) != 1 || names[0] != "alpha" {
t.Fatalf("got %v, want [alpha]", names)
}
}
func errUnknownFlag(body string) error {
return &stubFlagErr{msg: "unknown flag: --" + body}
}
type stubFlagErr struct{ msg string }
func (e *stubFlagErr) Error() string { return e.msg }
+133
View File
@@ -0,0 +1,133 @@
// 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 cmdutil
import (
"strings"
"unicode"
"unicode/utf8"
)
// SuffixLooksLikeValue decides whether a candidate suffix from a glued
// "--flagsuffix" token plausibly represents a value for a flag's
// declared type/format/enum. Shared by StickyHandler (PreParse) and
// SuggestFlagFix (unknown-flag recovery).
//
// typ is a pflag value type string (e.g. "int", "bool", "string");
// format is JSON Schema "format" when present (e.g. "date-time");
// enum is the schema enum list when present.
func SuffixLooksLikeValue(suffix, typ, format string, enum []string) bool {
if suffix == "" {
return false
}
switch typ {
case "int", "int8", "int16", "int32", "int64",
"uint", "uint8", "uint16", "uint32", "uint64",
"integer", "number", "float", "float32", "float64",
"count":
return startsWithNumericSuffix(suffix)
case "bool", "boolean":
return isBoolLiteralSuffix(suffix)
case "duration":
return startsWithDigitSuffix(suffix) || startsWithSignSuffix(suffix)
case "stringSlice", "stringArray", "intSlice", "boolSlice", "float32Slice",
"float64Slice", "uintSlice", "durationSlice", "ipSlice", "array":
return false
case "object":
return false
}
if len(enum) > 0 {
return matchesEnumSuffix(suffix, enum)
}
switch strings.ToLower(format) {
case "date", "date-time", "datetime", "time":
return startsWithDigitSuffix(suffix)
case "duration":
return startsWithDigitSuffix(suffix) || startsWithSignSuffix(suffix)
case "email":
return strings.Contains(suffix, "@")
case "uri", "url":
lower := strings.ToLower(suffix)
return strings.HasPrefix(lower, "http") ||
strings.HasPrefix(lower, "ftp") ||
strings.HasPrefix(lower, "mailto:")
case "ipv4", "ipv6", "hostname":
return startsWithDigitSuffix(suffix)
case "uuid":
first, _ := utf8.DecodeRuneInString(suffix)
return isHexRuneSuffix(first)
}
first, _ := utf8.DecodeRuneInString(suffix)
if first == utf8.RuneError || unicode.IsLetter(first) {
return false
}
return true
}
func startsWithDigitSuffix(s string) bool {
if s == "" {
return false
}
c := s[0]
return c >= '0' && c <= '9'
}
func startsWithSignSuffix(s string) bool {
if s == "" {
return false
}
c := s[0]
return c == '+' || c == '-'
}
func startsWithNumericSuffix(s string) bool {
if startsWithDigitSuffix(s) {
return true
}
if startsWithSignSuffix(s) && len(s) > 1 {
c := s[1]
return c >= '0' && c <= '9'
}
return false
}
func isBoolLiteralSuffix(s string) bool {
switch strings.ToLower(s) {
case "true", "false", "1", "0", "t", "f", "yes", "no", "on", "off", "y", "n":
return true
}
return false
}
func matchesEnumSuffix(s string, enum []string) bool {
lower := strings.ToLower(s)
for _, e := range enum {
if strings.ToLower(e) == lower {
return true
}
}
return false
}
func isHexRuneSuffix(r rune) bool {
return (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')
}
+91
View File
@@ -0,0 +1,91 @@
// 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 cmdutil
import "testing"
// TestSuffixLooksLikeValue_UTF8FirstRune locks in the UTF-8 first-rune
// reading contract on SuffixLooksLikeValue. The function previously read
// suffix[0] (a single byte) which produced incorrect splits / matches for
// any multi-byte first rune — relevant because dws is a Chinese-language
// CLI and value text often starts with CJK characters.
func TestSuffixLooksLikeValue_UTF8FirstRune(t *testing.T) {
t.Parallel()
cases := []struct {
name string
suffix string
typ string
format string
enum []string
want bool
}{
{
// --name张三 must NOT be split: with no metadata the fallback
// branch should treat a CJK letter as "not a value-looking
// suffix" so cobra reports unknown flag instead of cutting
// the user's typo into --name + 张三.
name: "plain string + CJK letter suffix refuses split",
suffix: "张三",
typ: "string",
want: false,
},
{
// CJK leading rune is fine as long as the email-format guard
// ('@' anywhere in suffix) still passes.
name: "email format + CJK leading + @ allows split",
suffix: "张三@example.com",
typ: "string",
format: "email",
want: true,
},
{
// uuid format: first rune must be hex. CJK starting char is
// not hex so the suffix must be rejected.
name: "uuid format + CJK leading rejects split",
suffix: "张abcd",
typ: "string",
format: "uuid",
want: false,
},
{
// Baseline: digit-led suffix on int still splits — guards
// against accidental over-tightening of the fallback path.
name: "int + digit-led baseline still splits",
suffix: "100",
typ: "int",
want: true,
},
{
// Invalid UTF-8 (lone continuation byte 0x80) decodes as
// utf8.RuneError; the RuneError guard makes the fallback
// reject it instead of treating it as "non-letter, splittable".
name: "plain string + invalid UTF-8 leading byte refuses split",
suffix: "\x80abc",
typ: "string",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := SuffixLooksLikeValue(tc.suffix, tc.typ, tc.format, tc.enum)
if got != tc.want {
t.Errorf("SuffixLooksLikeValue(%q, %q, %q, %v) = %v, want %v",
tc.suffix, tc.typ, tc.format, tc.enum, got, tc.want)
}
})
}
}
+20 -5
View File
@@ -74,23 +74,38 @@ const (
DefaultPartition = "default/default"
)
// EditionPartition returns the cache partition for a given edition name.
// The open-source core (name == "" or "open") uses DefaultPartition; every
// other edition gets its own namespace to prevent cross-edition data
// leakage in the disk cache.
// IsOpenEdition reports whether an edition name maps to the open-source core.
//
// This helper takes the edition name as a parameter instead of calling
// edition.Get() so that pkg/config remains a leaf dependency — importable
// from internal/cli, internal/app, internal/cache, etc. without risking
// import cycles.
func IsOpenEdition(name string) bool {
name = strings.TrimSpace(name)
return name == "" || name == "open"
}
// EditionPartition returns the cache partition for a given edition name.
// The open-source core (name == "" or "open") uses DefaultPartition; every
// other edition gets its own namespace to prevent cross-edition data
// leakage in the disk cache.
func EditionPartition(name string) string {
name = strings.TrimSpace(name)
if name == "" || name == "open" {
if IsOpenEdition(name) {
return DefaultPartition
}
return name + "/default"
}
// EditionFileName returns the edition-partitioned file name for base+ext.
func EditionFileName(name, base, ext string) string {
name = strings.TrimSpace(name)
if IsOpenEdition(name) {
return base + ext
}
return base + "-" + name + ext
}
// ── Auth flow timeouts ──────────────────────────────────────────────────
const (
+42
View File
@@ -96,6 +96,48 @@ func TestEditionPartition(t *testing.T) {
}
}
func TestIsOpenEdition(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
want bool
}{
{"empty is open", "", true},
{"open is open", "open", true},
{"whitespace open is open", " open ", true},
{"wukong is sibling", "wukong", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := IsOpenEdition(tc.input); got != tc.want {
t.Fatalf("IsOpenEdition(%q) = %v, want %v", tc.input, got, tc.want)
}
})
}
}
func TestEditionFileName(t *testing.T) {
t.Parallel()
cases := []struct {
name string
edition string
want string
}{
{"empty uses legacy filename", "", "app.json"},
{"open uses legacy filename", "open", "app.json"},
{"whitespace is trimmed", " wukong ", "app-wukong.json"},
{"sibling is suffixed", "wukong", "app-wukong.json"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := EditionFileName(tc.edition, "app", ".json"); got != tc.want {
t.Fatalf("EditionFileName(%q, \"app\", \".json\") = %q, want %q", tc.edition, got, tc.want)
}
})
}
}
func TestManualTokenExpiry(t *testing.T) {
t.Parallel()
if ManualTokenExpiry <= 0 {
+7 -1
View File
@@ -1,6 +1,6 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件时使用。
description: 管理钉钉产品能力(AI表格/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。
cli_version: ">=1.0.15"
---
@@ -37,7 +37,9 @@ cli_version: ">=1.0.15"
| `oa` | OA审批:待办/我发起的/表单模板/详情/审批流水/同意/拒绝/撤销 | [oa.md](./references/products/oa.md) |
| `report` | 日志:按模版创建/收件箱/已发送/模版查看/详情/已读统计 | [report.md](./references/products/report.md) |
| `mail` | 邮箱:邮箱地址查询/邮件搜索(KQL)/邮件详情/发送邮件 | [mail.md](./references/products/mail.md) |
| `sheet` | 在线电子表格(axls):工作表 CRUD/区域读写/行列增删/合并/查找替换/筛选视图/导出(两步)/图片 | [sheet.md](./references/products/sheet.md) |
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 | [wiki.md](./references/products/wiki.md) |
## 意图判断决策树
@@ -54,7 +56,9 @@ cli_version: ">=1.0.15"
用户提到"邮箱/邮件/发邮件/收邮件/搜邮件/查邮件" → `mail`
用户提到"审批/请假/报销/出差/加班/同意/拒绝/撤销审批" → `oa`
用户提到"日志/日报/周报/日志统计/写日报/提交周报/发日志/填日志" → `report`
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
用户提到"知识库/wiki/团队空间/知识库成员管理" → `wiki`
关键区分: aitable(数据表格) vs todo(待办任务)
关键区分: report(钉钉日志/日报周报) vs todo(待办任务)
@@ -99,6 +103,7 @@ Step 3 → 加 --yes 执行命令
## 核心流程
作为一个智能助手,你的首要任务是**理解用户的真实、完整的意图**,而不是简单地执行命令。在选择 `dws` 的产品命令前,必须严格遵循以下四步流程:
0. **URL 预检**:输入含 `alidocs.dingtalk.com` URL 时,该域名下存在多种路径格式(`/i/nodes/...`、`/i/p/...`、`/spreadsheetv2/...`、`/document/edit|preview?dentryKey=...` 等),每种的处理流程不同。**必须先读取 [url-patterns.md](./references/url-patterns.md) 中的「alidocs URL 分流决策」**,按其中规则识别 URL 类型后再选择对应产品。含 `shanji.dingtalk.com` URL 时直接路由到 `minutes`。URL 已识别后直接进入对应产品流程,无需后续步骤。
1. 意图分类:首先,判断用户指令的核心 动词/动作 属于哪一类。这比关注名词更重要。
2. 歧义处理与信息追问:如果用户指令模糊或包含多个产品的关键字,严禁猜测。必须主动向用户追问以澄清意图。这是你作为智能助手而非命令执行器的核心价值。
3. 精准产品映射:在完成前两步,意图已经清晰后,参考产品总览和意图判断决策树 来选择产品。
@@ -140,6 +145,7 @@ dws schema <path> --jq '.tool.required' # 只看必填字段
- [references/products/](./references/products/) — 各产品命令详细参考(flag 细节以 `--help` / `dws schema` 为准)
- [references/intent-guide.md](./references/intent-guide.md) — 意图路由指南(易混淆场景对照)
- [references/url-patterns.md](./references/url-patterns.md) — URL 格式规范 + alidocs URL 分流决策与类型探测流程(含钉盘 `document/edit|preview?dentryKey=` 链接)
- [references/global-reference.md](./references/global-reference.md) — 全局标志、认证、输出格式
- [references/field-rules.md](./references/field-rules.md) — AI表格字段类型规则
- [references/error-codes.md](./references/error-codes.md) — 错误码 + 调试流程
+11 -10
View File
@@ -183,7 +183,7 @@ Flags:
## message send — 以当前用户身份发消息
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。必须提供 --title 作为消息标题。
--group 指定群聊 ID 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。消息内容为位置参数(恰好 1 个),支持 Markdown。`--title` 是消息标题,**群聊与单聊都必填**(API 强制要求;缺失时服务端返回误导性的 "发群服务窗会话消息失败",CLI 现在前置校验直接报错)。
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
@@ -191,15 +191,15 @@ Flags:
Usage:
dws chat message send [flags] [<text>]
Example:
dws chat message send --group <openconversation_id> --text "hello"
dws chat message send --user <userId> --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --text "请查收"
dws chat message send --group <openconversation_id> "hello"
dws chat message send --group <openconversation_id> --title "周报" --text "请提交本周日报"
dws chat message send --user <userId> --title "提醒" --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请查收"
dws chat message send --group <openconversation_id> --title "通知" "hello"
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
dws chat message send --group <openconversation_id> --at-all "<@all> 请大家注意"
dws chat message send --group <openconversation_id> --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
dws chat message send --group <openconversation_id> --media-id <mediaId>
dws chat message send --open-dingtalk-id <openDingTalkId> --media-id <mediaId>
dws chat message send --group <openconversation_id> --title "通知" --at-all "<@all> 请大家注意"
dws chat message send --group <openconversation_id> --title "通知" --at-users userId1,userId2 "<@userId1> <@userId2> 请查收"
dws chat message send --group <openconversation_id> --title "图片" --media-id <mediaId>
dws chat message send --open-dingtalk-id <openDingTalkId> --title "图片" --media-id <mediaId>
Flags:
--text string 消息内容(推荐使用,也可用位置参数)
--group string 群聊 openconversation_id(群聊时必填)
@@ -219,6 +219,7 @@ Flags:
注意:
- --text 和位置参数二选一,--text 优先
- --title 必填(群聊与单聊都必填,API 强制要求)
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-all / --at-users / --at-mobiles 仅在 --group 群聊时生效;当设置--at-all时,消息内容中一定要包含对应的占位符<@all>;当设置--at-users userId1,userId2时,消息内容中一定要包含对应格式的占位符<@userId1> <@userId2>
@@ -722,7 +723,7 @@ dws drive download --file-id <dentryUuid> --format json
# Step 5: 用 Markdown 图片语法发送
dws chat message send --group <openconversation_id> \
--text "![截图](下载链接)" --format json
--title "截图" --text "![截图](下载链接)" --format json
```
## 上下文传递表
+16 -5
View File
@@ -50,6 +50,8 @@ Usage:
Example:
dws doc info --node <DOC_ID>
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
dws doc info --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>"
dws doc info --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>"
Flags:
--node string 文档 ID 或 URL (必填)
```
@@ -61,6 +63,8 @@ Usage:
Example:
dws doc read --node <DOC_ID>
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=<DENTRY_KEY>"
dws doc read --node "https://alidocs.dingtalk.com/document/preview?dentryKey=<DENTRY_KEY>"
Flags:
--node string 文档 ID 或 URL (必填)
```
@@ -328,12 +332,14 @@ Flags:
|------|------|----------------|
| `alidocs.dingtalk.com/i/nodes/{id}` | `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` | 取 URL 路径最后一段:`9E05BDRVQePjzLkZt2p2vE7kV63zgkYA` |
| `alidocs.dingtalk.com/i/nodes/{id}?queryParams` | `https://alidocs.dingtalk.com/i/nodes/abc123?doc_type=wiki_doc` | 忽略 query 参数,取路径最后一段:`abc123` |
| `alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | `https://alidocs.dingtalk.com/document/edit?dentryKey=wo1g3x54FzVEJ5yE` | **不要提取 `dentryKey` 单独使用**,必须将完整 URL 原样传给 `--node` |
### 提取规则
1. 匹配 URL 中 `alidocs.dingtalk.com` 域名
2. 取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
3. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
2. 路径为 `/i/nodes/{id}` 时,取 URL path 的最后一段作为 DOC_ID(去掉 query string 和 fragment)
3. 路径为 `/document/edit` 或 `/document/preview` 且 query 含 `dentryKey` 时,**禁止**提取 `dentryKey` 当 DOC_ID;将整段 URL 原样传给 `--node`,CLI 会自动解析(追踪参数如 `utm_source`、`chInfo` 也不必清理)
4. 提取出的 DOC_ID 可直接用于所有 `--node` 参数,也可将完整 URL 传给 `--node`(CLI 会自动解析)
### 处理流程
@@ -532,18 +538,23 @@ dws doc rename --node <DOC_ID> --name "项目周报 v2" --format json
| `file create` | `nodeId` | 后续 read / update / block 操作的 --node(仅 adoc 支持 read/update,axls/amind 等类型用各自产品的命令) |
| `copy` / `move` | 新 `nodeId`(copy)或原 nodeId(move) | 后续 read / info 等的 --node |
## nodeId 双格式说明
## nodeId 多格式说明
所有 `--node` 参数同时支持两种格式,系统自动识别:
所有 `--node` 参数同时支持以下格式,系统自动识别:
- **文档 ID**: 字母数字字符串,如 `9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
- **文档 URL**: `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}`,如 `https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA`
- **文档链接(edit/preview)**: `https://alidocs.dingtalk.com/document/{edit|preview}?...&dentryKey={key}`(必须传入完整 URL,不要提取其中的 query 参数单独使用)
两种方式等价,以下命令效果相同:
以下命令效果相同:
```bash
dws doc read --node 9E05BDRVQePjzLkZt2p2vE7kV63zgkYA
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/9E05BDRVQePjzLkZt2p2vE7kV63zgkYA"
dws doc read --node "https://alidocs.dingtalk.com/document/edit?dentryKey=wo1g3x54FzVEJ5yE"
dws doc read --node "https://alidocs.dingtalk.com/document/preview?cid=74993670680&type=d&docKey=Pd6l2Z7V8ZWydl7M&dentryKey=rBGBr2r1HmwanAGW"
```
> **注意**:`document/edit` 和 `document/preview` 格式 URL 中的 `dentryKey` 参数值不是合法的独立 nodeId,禁止提取后单独使用,必须传入完整 URL。URL 中可能包含 `utm_source`、`chInfo` 等追踪参数,无需手动去除,直接传入完整 URL 即可。
`--folder` 参数同样支持文件夹 URL 或 ID。
## 注意事项
+304
View File
@@ -0,0 +1,304 @@
# 在线电子表格 (sheet) 命令参考
## 适用范围(重要)
`sheet` 产品**仅支持钉钉在线电子表格**(`contentType=ALIDOC`、`extension=axls`),**不支持**上传的 `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件。
| 文件类型 | 处理方式 |
|---------|---------|
| 在线电子表格(`axls`) | 走 `sheet` 全部命令(读/写/筛选/合并/导出等服务端原子操作) |
| `xlsx` / `xls` / `xlsm` / `csv` 等本地表格文件 | 必须用 `dws doc download --node <ID> --output <路径>` 先下载到本地再用本地工具解析,**禁止**调用任何 `sheet` 子命令 |
| 想把在线表格导出为 xlsx | 用 `dws sheet submit_export_job` 提交导出任务 → 拿到 `jobId` → `dws sheet query_export_job` 轮询直到 `finished` → 用返回的 `downloadUrl` 下载 |
> 用户直接粘贴 `alidocs` URL 时,先用 `dws doc info --node <URL> --format json` 确认 `contentType=ALIDOC` 且 `extension=axls` 后再走 `sheet`;否则转 `dws doc download`。
## 命名风格说明(v1.0.25 envelope 现状)
`sheet` 产品的命令 cli_name **当前同时存在两种风格**——这是 envelope schema 还在演进中、`CLIAliases` (#246) 重命名尚未完成的过渡态:
- **kebab-case** (~17 个):`add-dimension`、`merge-cells`、`filter-view update-criteria` 等,与 dws 其它产品风格一致
- **snake_case** (~12 个):`copy_sheet`、`submit_export_job`、`set_filter_criteria` 等,envelope 原始名直透出来
**调用时以 `dws sheet --help` 输出为准**——本文档与 envelope schema 同步,未来命名收敛后会同步更新。所有命令的最终参数名以 `dws sheet <cmd> --help` 和 `dws schema sheet.<canonical_path>` 为准。
## 命令总览(按功能分组)
### 工作表 (Worksheet) 级
| 命令 | canonical | 用途 |
|------|-----------|------|
| `dws sheet create` | `sheet.create_workspace_sheet` | 在知识库中创建一个新的钉钉表格文档 |
| `dws sheet new` | `sheet.create_sheet` | 在已有钉钉表格文档中新建一张工作表 |
| `dws sheet list` | `sheet.get_all_sheets` | 列出指定文档的所有工作表 |
| `dws sheet info` | `sheet.get_sheet` | 获取指定工作表详情 |
| `dws sheet copy_sheet` | `sheet.copy_sheet` | 复制工作表(同文档内) |
| `dws sheet update_sheet` | `sheet.update_sheet` | 更新工作表元信息(如改名) |
### 区域 (Range) 读写
| 命令 | canonical | 用途 |
|------|-----------|------|
| `dws sheet range read` | `sheet.get_range` | 读取指定区域的单元格内容 |
| `dws sheet range update` | `sheet.update_range` | 写入/更新指定区域的单元格 |
| `dws sheet append` | `sheet.append_rows` | 在工作表末尾追加若干行 |
### 行列 (Dimension)
| 命令 | canonical | 用途 |
|------|-----------|------|
| `dws sheet add-dimension` | `sheet.add_dimension` | 在末尾追加空行或空列 |
| `dws sheet insert-dimension` | `sheet.insert_dimension` | 在指定位置插入空行/空列 |
| `dws sheet delete-dimension` | `sheet.delete_dimension` | 删除指定位置起的若干行/列 |
| `dws sheet move-dimension` | `sheet.move_dimension` | 移动行/列到指定位置 |
| `dws sheet update-dimension` | `sheet.update_dimension` | 更新行/列属性(显隐、行高/列宽) |
### 单元格合并
| 命令 | canonical | 用途 |
|------|-----------|------|
| `dws sheet merge-cells` | `sheet.merge_cells` | 合并指定范围的单元格(`mergeAll`/`mergeRows`/`mergeColumns`) |
| `dws sheet unmerge-cells` | `sheet.unmerge_range` | 取消指定范围的合并 |
### 查找/替换
| 命令 | canonical | 用途 |
|------|-----------|------|
| `dws sheet find` | `sheet.find_cells` | 在工作表中搜索单元格内容(支持正则/整格匹配/隐藏) |
| `dws sheet replace` | `sheet.replace_all` | 全局查找替换 |
### 筛选视图 (Filter View) — 命名视图、按列条件、不影响表本身
| 命令 | canonical | 用途 |
|------|-----------|------|
| `dws sheet filter-view create` | `sheet.create_filter_view` | 创建筛选视图 |
| `dws sheet filter-view list` | `sheet.get_filter_views` | 列出工作表的所有筛选视图 |
| `dws sheet filter-view update` | `sheet.update_filter_view` | 更新筛选视图(名称/范围/条件) |
| `dws sheet filter-view delete` | `sheet.delete_filter_view` | 删除整个筛选视图 |
| `dws sheet filter-view update-criteria` | `sheet.set_filter_view_criteria` | 设置/更新视图内某列的筛选条件 |
| `dws sheet filter-view delete-criteria` | `sheet.clear_filter_view_criteria` | 清除视图内某列的筛选条件 |
### 表级筛选 (Filter) — 直接作用于工作表本身的临时筛选
| 命令 | canonical | 用途 |
|------|-----------|------|
| `dws sheet create_filter` | `sheet.create_filter` | 在工作表上创建筛选器 |
| `dws sheet get_filter` | `sheet.get_filter` | 获取当前筛选器配置 |
| `dws sheet update_filter` | `sheet.update_filter` | 更新筛选器条件 |
| `dws sheet delete_filter` | `sheet.delete_filter` | 删除筛选器 |
| `dws sheet set_filter_criteria` | `sheet.set_filter_criteria` | 设置某列的筛选条件 |
| `dws sheet clear_filter_criteria` | `sheet.clear_filter_criteria` | 清除某列的筛选条件 |
| `dws sheet sort_filter` | `sheet.sort_filter` | 对筛选范围按指定列排序 |
### 图片
| 命令 | canonical | 用途 |
|------|-----------|------|
| `dws sheet write-image` | `sheet.write_image` | 将已上传的图片资源写入指定单元格 |
### 导出(两步原子,**v1.0.25 没有合并的 `export` 命令**)
| 命令 | canonical | 用途 |
|------|-----------|------|
| `dws sheet submit_export_job` | `sheet.submit_export_job` | 提交导出任务,返回 `jobId` |
| `dws sheet query_export_job` | `sheet.query_export_job` | 轮询导出任务状态,完成后返回 `downloadUrl` |
> v1.0.25 envelope 暴露的是这两个**原子动作**。要完整完成"导出 → 下载"流程需要 client 侧轮询 + 调 `downloadUrl`。`CLIToolOverride.Pipeline` (#247) 提供了底层编排能力,但**当前 envelope 还没把这两个动作 Pipeline 化成一条 `dws sheet export` 总命令**。
## 通用必填参数
绝大多数 `sheet` 命令都需要:
- `--node <NODE_ID>` —— 钉钉表格文档的 nodeId 或 `https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>` URL(alias 来自 `nodeId`)
- `--sheet-id <SHEET_ID>` —— 工作表 ID(alias 来自 `sheetId`),从 `dws sheet list` 拿
例外:
- `create` 只需要 `--name`(在知识库创建文档时不需要 nodeId)
- `list` / `info` / `range read` 只需要 `--node`
- `submit_export_job` 只需要 `--node` + `--export-format`(无 sheet-id)
- `query_export_job` 只需要 `--job-id`
## 常用命令示例
### 创建文档 + 新建工作表
```bash
# 在知识库下创建一个钉钉表格文档
dws sheet create --name "销售数据" --workspace <WS_ID> --format json
# 返回的 nodeId 用于后续操作
# 在已有文档中新建一张工作表
dws sheet new --node <NODE_ID> --name "Q1 数据" --format json
```
### 读写区域
```bash
# 读 A1:D10
dws sheet range read --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D10" --format json
# 写入 5x4 区域(values 是二维 JSON 数组)
dws sheet range update --node <NODE_ID> --sheet-id <SHEET_ID> --range "A1:D5" \
--values '[["姓名","岗位","入职","薪资"],["张三","研发","2024-01","30000"]]' \
--format json
# 追加行
dws sheet append --node <NODE_ID> --sheet-id <SHEET_ID> \
--values '[["李四","产品","2025-03","28000"]]' \
--format json
```
### 行列操作
```bash
# 在第 5 行处插入 2 个空行
dws sheet insert-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension rows --position 4 --length 2 --format json
# 末尾追加 3 列
dws sheet add-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension columns --length 3 --format json
# 删除第 10-12 行
dws sheet delete-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension rows --position 9 --length 3 --format json
# 隐藏 B 列(startIndex=1, length=1)
dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
--dimension columns --start-index 1 --length 1 --hidden true --format json
```
### 合并/取消合并
```bash
# 合并 A1:C1
dws sheet merge-cells --node <NODE_ID> --sheet-id <SHEET_ID> \
--range "A1:C1" --merge-type mergeAll --format json
# 取消合并
dws sheet unmerge-cells --node <NODE_ID> --sheet-id <SHEET_ID> \
--range "A1:C1" --format json
```
### 查找/替换
```bash
# 查找
dws sheet find --node <NODE_ID> --sheet-id <SHEET_ID> \
--find "TODO" --use-regexp false --match-case false --format json
# 全局替换
dws sheet replace --node <NODE_ID> --sheet-id <SHEET_ID> \
--find "TODO" --replacement "DONE" --format json
```
### 筛选视图(推荐:可命名、不破坏原表)
```bash
# 创建筛选视图(范围必须包含表头行)
dws sheet filter-view create --node <NODE_ID> --sheet-id <SHEET_ID> \
--name "未完成项" --range "A1:E100" --format json
# 返回的 filterViewId 用于后续 update/delete/criteria 操作
# 列出所有筛选视图
dws sheet filter-view list --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 给视图的某一列设置筛选条件(column 是相对视图范围首列的 0-based 偏移)
dws sheet filter-view update-criteria --node <NODE_ID> --sheet-id <SHEET_ID> \
--filter-view-id <FV_ID> --column 2 \
--filter-criteria '{"conditions":[{"type":"TEXT_CONTAINS","values":["pending"]}]}' \
--format json
# 清除某列的筛选条件(不删除视图本身)
dws sheet filter-view delete-criteria --node <NODE_ID> --sheet-id <SHEET_ID> \
--filter-view-id <FV_ID> --column 2 --format json
# 删除整个筛选视图
dws sheet filter-view delete --node <NODE_ID> --sheet-id <SHEET_ID> \
--filter-view-id <FV_ID> --format json
```
### 表级筛选(snake_case 系列,直接作用于工作表本身)
```bash
# 创建筛选器(一张表只有一个,再次 create 会替换)
dws sheet create_filter --node <NODE_ID> --sheet-id <SHEET_ID> \
--range "A1:E100" --format json
# 给某列加筛选条件
dws sheet set_filter_criteria --node <NODE_ID> --sheet-id <SHEET_ID> \
--column 0 --filter-criteria '{...}' --format json
# 按指定列排序
dws sheet sort_filter --node <NODE_ID> --sheet-id <SHEET_ID> --field 0 --format json
# 删除筛选器
dws sheet delete_filter --node <NODE_ID> --sheet-id <SHEET_ID> --format json
```
### 复制工作表
```bash
dws sheet copy_sheet --node <NODE_ID> --sheet-id <SHEET_ID> --format json
# 返回新工作表 ID
```
### 写入图片
```bash
# 已有图片资源 ID 和 URL(通过 drive 或 doc 上传得到)后写入单元格
dws sheet write-image --node <NODE_ID> --sheet-id <SHEET_ID> \
--range "B2:B2" --resource-id <RES_ID> --resource-url <RES_URL> \
--width 200 --height 100 --format json
```
### 导出 xlsx(两步流程)
```bash
# Step 1: 提交导出任务
JOB=$(dws sheet submit_export_job --node <NODE_ID> --export-format xlsx --format json --jq '.result.jobId')
# Step 2: 轮询任务状态(建议 sleep + 重试)
dws sheet query_export_job --job-id "$JOB" --format json
# 直到返回 status=finished + downloadUrl
# Step 3: 下载(用 curl / dws doc download / 其它工具拉 downloadUrl)
```
## 易混淆点
| 区分 | 说明 |
|---|---|
| `dws sheet create` vs `dws sheet new` | `create` 在知识库**新建一个文档**(返回新 nodeId);`new` 在**已有文档中新建一张工作表**(需 nodeId) |
| `dws sheet filter-view *` vs `dws sheet create_filter`/`set_filter_criteria` 等 | filter-view 是**命名视图**,多个并存、不影响表本身;filter 是**表级唯一**筛选器,直接作用于工作表显示 |
| `filter-view update-criteria` vs `filter-view delete-criteria` | update 是设置/覆盖列条件;delete 是清除列条件(视图本身保留);要删整个视图用 `filter-view delete` |
| `dws sheet submit_export_job` + `query_export_job` vs `dws sheet export` | 后者**不存在**于 v1.0.25 envelope。需 client 端自己轮询,或基于 Pipeline (#247) 在 envelope 侧 PR 一条总命令 |
| `dws sheet write-image` vs `range update` | write-image 写入图片(需 resourceId + resourceUrl);range update 写入文本/数字/公式 |
| `range update` vs `append` | range update 指定区域覆盖;append 在末尾追加行 |
| online axls vs 本地 xlsx | sheet 全部命令只认 axls;本地 xlsx 必须先 `doc download` 再用本地工具解析 |
## 危险操作(必须先向用户确认)
| 命令 | 风险 |
|---|---|
| `delete-dimension` | 删除行/列(含数据),不可恢复 |
| `filter-view delete` | 删除整个筛选视图 |
| `delete_filter` | 删除表级筛选器 |
| `replace` | 全局替换可能影响大量单元格 |
| `unmerge-cells` | 取消合并可能丢失部分单元格内容(钉钉行为依赖合并模式) |
| `update_sheet` | 更新工作表元信息(如改名) |
执行前先 `--dry-run` 预览,并向用户展示操作摘要 + 拿到明确同意,再加 `--yes` 提交。
## 何时**不要**用 sheet
- 用户给的是 `xlsx` / `xls` / `xlsm` / `csv` 本地文件 → 用 `dws doc download` 下载后本地解析
- 用户给的是 AI 表格(不是在线电子表格)→ 用 `dws aitable record query` 等
- 用户给的是富文本/普通文档 → 用 `dws doc read`
## 权威参考
- 列出所有 sheet 工具:`dws schema | jq '.products[] | select(.id=="sheet") | .tools[] | "\(.group) \(.cli_name)"' -r`
- 看某个命令的完整 JSON Schema:`dws schema sheet.<canonical_path>`(如 `dws schema sheet.update_range`)
- 看某个命令的 flag 别名映射:`dws schema sheet.<canonical_path> --jq '.tool.flag_overlay'`
- 看必填字段:`dws schema sheet.<canonical_path> --jq '.tool.required'`
- 命令的人读视图:`dws sheet <cmd> --help`
+177
View File
@@ -0,0 +1,177 @@
# 知识库 (wiki) 命令参考
## 命令总览
### 创建知识库
```
Usage:
dws wiki space create [flags]
Example:
dws wiki space create --name "产品文档库" --format json
dws wiki space create --name "技术方案" --description "团队技术方案归档" --format json
Flags:
--name string 知识库名称 (必填,不超过 100 字符)
--description string 知识库描述 (选填,不超过 500 字符)
--icon string 知识库图标标识 (选填)
```
### 查看知识库详情
```
Usage:
dws wiki space get [flags]
Example:
dws wiki space get --id <workspaceId> --format json
dws wiki space get --id "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --format json
Flags:
--id string 知识库 ID 或 URL (必填)
```
支持传入知识库 ID 或知识库 URL,系统自动识别。
知识库 URL 格式:`https://alidocs.dingtalk.com/i/spaces/{workspaceId}/overview`
### 列出知识库
```
Usage:
dws wiki space list [flags]
Example:
dws wiki space list --format json
dws wiki space list --type myWikiSpace --format json
dws wiki space list --type orgWikiSpace --limit 50 --format json
Flags:
--type string 知识库类型: myWikiSpace / orgWikiSpace (默认 orgWikiSpace)
--limit string 每页数量 1-50 (默认 20)
--page-token string 分页游标 (首页留空)
```
- `myWikiSpace`:返回当前用户的「我的文档」个人空间(固定 1 条,不支持分页)
- `orgWikiSpace`(默认):返回组织内有权访问的知识库列表,支持分页
### 搜索知识库
```
Usage:
dws wiki space search [flags]
Example:
dws wiki space search --keyword "产品文档" --format json
dws wiki space search --keyword "技术方案" --limit 20 --format json
dws wiki space search --type myWikiSpace --format json
Flags:
--keyword string 搜索关键词 (--type myWikiSpace 时可省略)
--type string 知识库类型: myWikiSpace 时直接返回「我的文档」,省略则搜索组织知识库
--limit string 返回数量 1-20 (默认 10)
```
当 `--type myWikiSpace` 时,忽略 `--keyword`,直接返回「我的文档」个人空间。
### 添加知识库成员(容器级授权)
```
Usage:
dws wiki member add [flags]
Example:
dws wiki member add --space <WS_ID> --user uid1 --role READER
dws wiki member add --space <WS_ID> --user uid1,uid2 --role EDITOR
dws wiki member add --space "https://alidocs.dingtalk.com/i/spaces/<WS_ID>/overview" --user uid1 --role MANAGER
Flags:
--space string 目标知识库 ID 或 URL (必填)
--user strings 被加入的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
--role string 授予的角色 (必填,大小写敏感,必须全大写): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
```
> **❗ 重要约束**:
> - 仅支持 USER 类型。
> - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。
> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。
> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,请改用 `dws doc permission add` 在节点级别授权。
### 修改知识库成员角色
```
Usage:
dws wiki member update [flags]
Example:
dws wiki member update --space <WS_ID> --user uid1 --role EDITOR
dws wiki member update --space <WS_ID> --user uid1,uid2 --role READER
Flags:
--space string 目标知识库 ID 或 URL (必填)
--user strings 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
--role string 新角色 (必填,大小写敏感,必须全大写): MANAGER / EDITOR / DOWNLOADER / READER
```
### 列出知识库成员
```
Usage:
dws wiki member list [flags]
Example:
dws wiki member list --space <WS_ID>
dws wiki member list --space <WS_ID> --max-results 100
dws wiki member list --space <WS_ID> --filter-role EDITOR
Flags:
--space string 目标知识库 ID 或 URL (必填)
--max-results int 返回数量上限,最大 200 (默认 50)
--filter-role string 按角色过滤: MANAGER / EDITOR / DOWNLOADER / READER (选填)
```
> 接口不支持游标分页,使用 `--max-results` 一次性拉取。
## 意图判断
- 用户说"创建知识库/新建知识库" → `space create`
- 用户说"查看知识库/知识库详情" → `space get`
- 用户说"我的知识库/知识库列表/有哪些知识库" → `space list`
- 用户说"搜索知识库/找知识库" → `space search`
- 用户说"我的文档/个人空间" → `space search --type myWikiSpace` 或 `space list --type myWikiSpace`
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--space` + `--user` + `--role`)
- 用户说"修改某人在知识库的权限/调整成员角色" → `member update`
- 用户说"知识库有哪些成员/查看知识库成员" → `member list`
关键区分:
- wiki(知识库空间级管理:创建/查询/列出/搜索/成员管理) vs doc(文档内容级操作:搜索/读写/编辑/节点级权限)
- wiki space(知识库容器) vs drive(钉盘文件存储/上传/下载)
- **wiki member**(容器级,授权整个知识库)vs **doc permission**(节点级,授权单篇文档)
- 「我的文档」**只能用** `doc permission`,不能用 `wiki member`
## 核心工作流
```bash
# 列出我有权访问的组织知识库
dws wiki space list --format json
# 获取「我的文档」个人空间
dws wiki space list --type myWikiSpace --format json
# 搜索知识库
dws wiki space search --keyword "产品" --format json
# 搜索「我的文档」
dws wiki space search --type myWikiSpace --format json
# 创建知识库
dws wiki space create --name "新项目文档" --description "项目相关文档归档" --format json
# 查看知识库详情
dws wiki space get --id <workspaceId> --format json
# ── 工作流: 给知识库加成员 ──
# 1. 先确认知识库 ID(避免授权到「我的文档」)
dws wiki space list --format json # 注意:不要 --type myWikiSpace
# 2. 添加成员
dws wiki member add --space <WS_ID> --user <UID> --role EDITOR --format json
# 3. 查看当前成员
dws wiki member list --space <WS_ID> --format json
```
## 上下文传递表
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `space create` | `workspaceId` | space get 的 --id / member add 的 --space |
| `space list` | `workspaceId` | space get 的 --id / member add 的 --space |
| `space search` | `workspaceId` | space get 的 --id / member add 的 --space |
| `space get` | `spaceUrl` | 分享给用户 |
| `member list` | `userId` | member update 的 --user |
## 相关产品
- [doc](./doc.md) — 文档内容级操作(搜索/读写/编辑文档、知识库内文档管理)
- [drive](./drive.md) — 钉盘文件存储/上传/下载
+127
View File
@@ -0,0 +1,127 @@
# URL 格式与处理规范
## alidocs URL 分流决策(必须首先执行)
收到 `alidocs.dingtalk.com` URL 时,**必须按以下顺序判断,禁止跳过**:
1. URL 路径含 `/i/p/` → **分享短链**,禁止调用 `dws doc` 任何子命令 → 按下方 [分享短链处理](#分享短链处理) 执行
2. URL 路径含 `/i/nodes/` → **节点链接**,需探测类型 → 按下方 [alidocs URL 类型探测流程](#alidocs-url-类型探测流程) 执行
3. URL 路径含 `/spreadsheetv2/` → **电子表格直链**,直接路由到 `sheet`,将完整 URL 原样传给 `--node` 参数
4. URL 路径含 `/document/edit` 或 `/document/preview` 且 query 参数包含 `dentryKey` → **文档链接**,直接路由到 `doc`,将完整 URL 原样传给 `--node` 参数(URL 中不一定有 `type=d`,只需匹配路径和 `dentryKey` 参数即可)
5. 其他 alidocs URL 格式 → 告知用户当前暂不支持该链接格式
---
## 已知 URL 格式
需要自行拼接链接时,只能使用以下模板:
| 产品 | 用途 | URL 格式 | ID 来源 |
|------|------|----------|---------|
| `aitable` | AI表格 Base 链接 | `https://alidocs.dingtalk.com/i/nodes/{baseId}` | `base list/search/create/get` 返回的 `baseId` |
| `aitable` | AI表格模板预览 | `https://docs.dingtalk.com/table/template/{templateId}` | `template search` 返回的 `templateId` |
| `doc` | 文档链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `doc` 命令返回的 `dentryUuid` |
| `sheet` | 电子表格链接 | `https://alidocs.dingtalk.com/i/nodes/{dentryUuid}` | `sheet create` 返回的 `dentryUuid` |
| `sheet` | 电子表格直链 | `https://alidocs.dingtalk.com/spreadsheetv2/{key}/...?dentryKey={key}&type=s` | 用户提供的完整 URL,直接传给 `--node` |
| `doc` | 文档链接(edit/preview) | `https://alidocs.dingtalk.com/document/{edit\|preview}?...&dentryKey={key}` | 用户提供的完整 URL,直接传给 `--node` |
| `minutes` | 听记链接 | `https://shanji.dingtalk.com/app/transcribes/{taskUuid}` | `list mine/shared` 返回的 `taskUuid` |
不在此表中的产品,禁止自行拼接 URL。命令返回中包含完整链接时直接使用,否则告知用户无法提供。
## 分享短链处理
`alidocs.dingtalk.com/i/p/{shortKey}` 是钉钉文档的**对外分享短链**,`dws doc` 命令无法解析此格式。
### 识别规则
URL 路径中包含 `/i/p/` 即为分享短链(无论后面是否还有子路径),例如:
- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2`
- `https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7`
- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234`
- `https://alidocs.dingtalk.com/i/p/AbCdEfGh1234/sheets/XYZ789`
> **关键**:只要 URL 中出现 `/i/p/`,无论后面跟什么子路径(`/docs/...`、`/sheets/...` 等),都属于分享短链,一律禁止调用 `dws doc`。
### 处理方式
**不要调用 `dws doc` 任何子命令**(包括 `doc info`、`doc read` 等),`dws` 无法解析此格式。
- **需要获取文档内容时**:使用 `read_url` 工具直接读取该链接
- **其他操作(如移动、复制、权限管理等)**:告知用户此链接为分享短链,无法直接执行复制、移动、权限管理等操作。如需保存该文档内容,建议用户在钉钉客户端中打开该页面,手动复制文本内容,然后可通过 `dws doc create` 创建一篇新文档并将内容写入
```
# 需要读取文档内容时(无论 /i/p/ 后面有没有子路径,都用 read_url)
read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2")
read_url("https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7")
# 禁止(以下全部会失败,dws 无法解析任何含 /i/p/ 的 URL)
dws doc info --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2" --format json
dws doc read --node "https://alidocs.dingtalk.com/i/p/Y7kmbokZp3pgGLq2/docs/AY39rGpMPmeVNpXZevZm8OZkXKnaoNQ7" --format json
```
### 当 `read_url` 返回内容不完整时
钉钉文档分享页是动态渲染的,`read_url` 可能只能获取到页面标题等有限信息,无法获取文档正文。此时**禁止猜测原因**(如"权限不足""文档为空""文档已删除"等),**禁止建议用户"提供 `/i/nodes/` 格式链接"**(分享短链和节点链接是不同体系,普通用户无法自行转换)。应直接告知用户:
> 这个链接是钉钉文档的分享短链,由于页面是动态渲染的,我无法通过该链接直接获取文档的完整正文内容。
>
> 你可以:
> 1. 在钉钉客户端中打开该文档,将正文内容复制粘贴给我
> 2. 如果文档已保存在你的文档空间中,可以告诉我文档名称,我通过 `dws doc search` 搜索后再读取
---
## alidocs URL 类型探测流程
`alidocs.dingtalk.com/i/nodes/{id}` 是钉钉文档空间的统一 URL,可能指向**文档、电子表格、多维表、文件、文件夹**等不同类型。**禁止仅凭 URL 就假定为文档**,必须先探测类型再路由到正确的产品。
### 探测步骤
```
Step 1 → dws doc info --node "<URL>" --format json
Step 2 → 从返回中提取 contentType、extension、nodeType 字段
Step 3 → 按下方路由规则映射到对应产品
```
### 路由映射表
| 条件 | 路由到产品 | 后续操作 |
|------|-----------|---------|
| `contentType=ALIDOC`, `extension=adoc` | `doc` | 按 [doc.md](./products/doc.md) 操作 |
| `contentType=ALIDOC`, `extension=axls` | `sheet` | 按 [sheet.md](./products/sheet.md) 操作(仅 `axls` 在线电子表格) |
| `contentType=ALIDOC`, `extension=able` | `aitable` | 将 nodeId 作为 baseId,按 [aitable.md](./products/aitable.md) 操作 |
| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws doc download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) |
| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws doc download` 下载,返回文件下载链接 |
| `nodeType=folder` | `doc` | 调用 `dws doc list --folder <ID>` 列出指定文件夹直接子节点列表 |
| 以上均不匹配 | — | 告知用户当前暂不支持该类型 |
> axls vs xlsx 关键区分:
> - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作)
> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错)
> - 用户想把在线表格导出为 xlsx 文件 → 用 `dws sheet submit_export_job` 提交导出任务(输入是 `axls`,输出是 xlsx,这是 axls → xlsx 的格式转换,不属于 xlsx 读取场景)
### 示例
```bash
# 用户传入: https://alidocs.dingtalk.com/i/nodes/abc123
dws doc info --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json
# 返回 contentType=ALIDOC, extension=axls → 在线电子表格,路由到 sheet
dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json
# 返回 contentType≠ALIDOC, extension=xlsx/xls/csv → 本地表格文件,必须下载处理(禁止走 sheet)
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456"
# 返回 contentType≠ALIDOC, nodeType=file → 普通文件,下载
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/def456"
# 返回 nodeType=folder → 文件夹,列出子节点
dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format json
```
### 何时可跳过探测
当用户指令中已明确指定产品(如"帮我读这个文档"、"看下这个表格的数据"),可结合用户意图**跳过探测**直接路由。仅在以下情况**必须执行探测**:
- 用户只粘贴 URL,无其他上下文
- 用户指令与 URL 实际类型可能不一致(如说"文档"但实际是表格)
- 用户直接粘贴的是原始 `alidocs` URL,且没有上游命令返回来确认类型