Compare commits

...
26 Commits
Author SHA1 Message Date
修雨 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
修雨 574d9aa2f7 docs(changelog): backfill 1.0.24 + add 1.0.25 release notes (#262)
- 1.0.24 (tag already pushed at 9e15115 on 2026-05-09): #248 embedded
  self-upgrade guard, #238 auth login help realignment, #261 release
  workflow_dispatch fallback.
- 1.0.25 (next release): #246 CLIAliases tool override + json_parse_strict
  transform, #247 Pipeline tool override + executor for multi-step
  workflows (submit → poll → download).
2026-05-11 13:56:12 +08:00
修雨 1aaaef0274 feat(schema): add CLIAliases tool override + json_parse_strict transform (#246)
Two generic CLI envelope schema enhancements that close gaps surfaced by
the cli_to_mcp test suite without resorting to product-specific helpers:

1. CLIToolOverride.CLIAliases (registry.go + dynamic_commands.go)
   - Lets a single MCP tool register additional cobra command aliases via
     envelope JSON (e.g. `range read` accepts `range get`, `member list`
     accepts `member ls`). Plumbs through the existing Route.Aliases ->
     cobra.Command.Aliases path; conflicts with siblings are silently
     skipped by cobra.

2. json_parse_strict transform (transform.go)
   - Strict JSON variant of json_parse that does NOT fall back to YAML.
     Use when the upstream tool requires a structured array/object value
     and silently coercing 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).
2026-05-11 10:56:19 +08:00
修雨 00c037b5be feat(schema): add Pipeline tool override + executor for multi-step workflows (#247)
A generic envelope schema feature that lets a single CLI command orchestrate
an ordered sequence of MCP tool calls plus optional HTTP-download sinks,
declared entirely in the envelope JSON. The motivating use case is the
"submit-job + poll-status + download-result" pattern (e.g. sheet export),
which previously required hardcoded helper commands per product.

## Schema additions

- `CLIToolOverride.Pipeline []PipelineStep` — when non-empty, dispatch
  ignores the parent map key (no single "primary tool") and walks the
  steps in order. CLI surface (CLIName / Group / Flags) still applies.
- `PipelineStep` struct — supports two step types:
  - `type:"call"` (default) invokes Tool with templated Args. Optional
    PollUntilField/Value turn it into a polling loop with configurable
    PollIntervalSec / PollTimeoutSec.
  - `type:"download"` resolves DownloadURLField, fetches via HTTP GET,
    and writes the body to the path supplied by OutputFlag's value
    (with directory-path filename inference).
- `CLIFlagOverride.PipelineLocal bool` — marks a flag as CLI-side only;
  CollectBindings skips it so the value never reaches MCP params, but
  the pipeline executor reads it via extractFlagValuesByAlias.

## Template language

Two prefixes supported in PipelineStep.Args / DownloadURLField:

  $flag.<name>           — value of the user's CLI flag whose alias is <name>
  $step.<idx>.<dotPath>  — field from a prior step's response (idx 0-based)
  literal                — passed through

dotPath walks nested map[string]any so "$step.1.content.downloadUrl"
resolves through wrapped MCP envelopes correctly.

## Stdout contract

The download step always emits machine-parseable plain-text lines
("jobId: <id>\\n", "downloadUrl: <url>\\n") in addition to the standard
JSON output, so shell pipelines and regex-based test suites can extract
key values without parsing JSON. Structured callers continue to consume
the output.WriteCommandPayload JSON envelope.

## Why this is schema enhancement, not product hardcode

- The executor is product-agnostic: any envelope can declare a Pipeline
  and benefit (sheet export today; doc/drive/aitable async patterns
  tomorrow).
- The template language is the only product-coupling, and it lives in
  the envelope JSON — not in Go code.
- No sheet/wiki/aitable-specific code in dingtalk-workspace-cli.

Files: 1 new + 3 modified (~250 LOC of executor + ~30 LOC of schema
plumbing). Existing tests pass; new pipeline tests TBA in a follow-up
once the schema fields are in upstream.
2026-05-11 10:56:10 +08:00
修雨 9e15115ad4 ci(release): add workflow_dispatch trigger as fallback (#261)
GitHub occasionally drops tag push events; this adds a manual trigger
so we can re-run the release job against any tag ref without having to
delete and re-push the tag.
2026-05-11 09:53:23 +08:00
xianfeng wangandgithub-actions[bot] 25bf3d12f2 feat(upgrade): block self-upgrade in embedded distributions (#248)
* 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

* upgrade not in embed

* remove comment

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-09 20:27:20 +08:00
修雨 f78cc5c846 docs(auth): correct login help to reflect actual default + SSH guidance (#226) (#238)
* docs(auth): correct login help to reflect actual default + SSH guidance (#226)

The --help long description for `dws auth login` claimed the default mode was
"OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback
listener (oauth_provider.go:131-136) and only switches to device flow when
--device is passed. SSH-into-headless-Linux users following the docs hit a
dead end because the local browser cannot reach 127.0.0.1 on the remote box.

Rewrite the description so each method is named after its real flag:
  - OAuth Loopback 流 (默认)
  - OAuth 设备流 (--device)
  - 直接提供 Token (--token)

Add an explicit warning callout and a `--device` example for SSH/headless
environments. Also realign two flagErrorWithSuggestions strings in root.go
that propagated the same misconception ("默认使用设备流" → loopback default,
SSH 用户加 --device).

No behavioural change.

* docs(auth): drop emoji from login help warning
2026-05-09 09:25:57 +08:00
修雨 72fe795f3f docs(changelog): add 1.0.23 release notes (#241)
Backfill 1.0.23 entry covering PR #237 (HTTP_PROXY/HTTPS_PROXY support
restored across the three custom http.Transport instances). Format
follows the 1.0.22 section: narrative + Fixed + Tests.
2026-05-08 20:41:11 +08:00
修雨 0e892c7d75 fix: honour HTTP_PROXY/HTTPS_PROXY in custom HTTP transports (#236) (#237)
The three custom http.Transport instances built by the CLI
(`internal/transport/client.go` MCP transport, `internal/apiclient/client.go`
DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry
client) all set DialContext / TLSClientConfig / timeouts but omit the Proxy
field. Per Go's net/http contract, a non-nil Transport without an explicit
Proxy means "no proxy" — env vars are silently ignored, breaking sandboxed
or air-gapped deployments that route outbound through HTTP_PROXY /
HTTPS_PROXY.

Set Proxy: http.ProxyFromEnvironment on all three. Adds a regression test
per package that pointer-compares the Transport's Proxy func against
http.ProxyFromEnvironment (avoids flakiness from Go's envProxyOnce
memoisation when running alongside tests that read proxy env early).
2026-05-08 16:07:14 +08:00
修雨 8995bf65d6 docs(changelog): backfill 1.0.20 / 1.0.21 / 1.0.22 release notes (#231) 2026-05-07 21:52:09 +08:00
ybc❤️zyrand猷诺 91af2bc3b8 fix(install): add .hermes/skills to AGENT_DIRS (#188) (#221)
* fix(install): add .hermes/skills to AGENT_DIRS (#188)

dws 安装后未自动复制 skill 到 .hermes/skills/。在 4 份硬编码的
AGENT_DIRS 清单(build/npm/install.js、scripts/install.sh、
scripts/install.ps1、scripts/install-skills.sh)末尾追加
.hermes/skills,与现有'父目录守卫'逻辑天然兼容:

- 已安装 Hermes 的用户:自动获得 ~/.hermes/skills/dws
- 未安装 Hermes 的用户:父目录不存在则跳过,零副作用

Closes #188

* fix(install): cover remaining 4 AGENT_DIRS sources for .hermes/skills (review feedback)

Address review feedback from #221: the AGENT_DIRS list actually has 8 sources
in the repo, not 4. Without this commit, dws upgrade users and Homebrew users
would still NOT get .hermes/skills/dws populated even after the previous
4-script fix landed.

Fixes (functional):
- internal/upgrade/paths.go (knownSkillDirs):
    Append .hermes/skills so 'dws upgrade' refreshes ~/.hermes/skills/dws/
    for users who have Hermes installed.
- build/homebrew.rb.tmpl (post_install targets):
    Append .hermes/skills/dws so brew users also get the skill copied to
    ~/.hermes/skills/dws on post_install.

Fixes (test / smoke coverage):
- test/scripts/package_script_test.go (expectedPackagedSkillTargets):
    Append .hermes/skills/dws so future regressions (e.g. someone removes
    .hermes from install.js) are caught by CI.
- scripts/release/verify-package-managers.sh (HOME_AGENT_PARENTS / HOME_SKILL_TARGETS):
    Append .hermes to both lists so the release-time npm/brew smoke test
    actually asserts ~/.hermes/skills/dws/SKILL.md exists.

Documentation:
- internal/upgrade/paths.go: expanded the 'Kept in sync with' comment from
    a single file reference (build/npm/install.js) to all 7 in-sync sources,
    so future maintainers don't have to rediscover this list.

Verification:
- go vet ./internal/upgrade/... ./test/scripts/...   PASS
- go build ./...                                     PASS
- sh -n scripts/install.sh                           PASS
- sh -n scripts/install-skills.sh                    PASS
- sh -n scripts/release/verify-package-managers.sh   PASS
- node --check build/npm/install.js                  PASS

---------

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-07 11:07:27 +08:00
ybc❤️zyrand猷诺 e2e8b3bf52 fix(attendance): summary 命令新增 --stats-type flag,修复 C0002 报错 (#228)
* fix(attendance): add --stats-type flag to summary to fix C0002 error

The MCP tool get_attendance_summary requires statsType in QueryUserAttendVO
at the server-side business layer (DingTalk schema marks it required:[] but
service rejects with C0002 'statistics type error' when omitted).

Changes:
- internal/helpers/attendance.go: add --stats-type flag (week/month);
  conditionally write statsType into QueryUserAttendVO when provided;
  update Long description and flag help to mark --stats-type as required
- test/cli_compat/attendance_test.go: add execSummaryDryRun helper and
  3 new tests verifying statsType is correctly threaded through VO
- skills/references/products/attendance.md: document --stats-type flag
  with warning about server-side mandatory enforcement

Verification:
- go build ./...                                                  PASS
- go vet ./internal/helpers/... ./test/cli_compat/...              PASS
- go test -run TestAttendanceSummary_should_pass_stats_type_*      PASS (3/3)
- go test -run TestAttendanceSummary_should_not_pass_stats_type_*  PASS
- Real CLI: dws attendance summary --stats-type month              success:true
  (C0002 fully gone; full attendance data returned)

Closes #227

* fix(attendance): enforce --stats-type as required at CLI layer (review feedback)

Address review feedback from #228:

1. Fail-fast validation: --stats-type is now required at the CLI layer
   instead of being conditionally written into VO. Previously the doc
   said 'required' but RunE silently let omission through, causing the
   same C0002 the fix was supposed to prevent.

2. Enum validation: only 'week' or 'month' are accepted; any other value
   is rejected at the CLI layer instead of being forwarded to the server.

3. Cosmetic: split the single-line Long description into multiple lines
   for readability.

Test changes:
- Replaced TestAttendanceSummary_should_not_pass_stats_type_when_omitted
  with two semantically-correct tests:
    * TestAttendanceSummary_should_error_when_stats_type_missing
    * TestAttendanceSummary_should_error_when_stats_type_invalid
- Updated other TestAttendanceSummary_* tests to pass --stats-type=month
  to match the new fail-fast contract.
- Renamed should_pass_only_user_flag to should_pass_user_id_through_vo
  (the old name no longer makes sense now that --stats-type is mandatory).

Skill doc:
- skills/references/products/attendance.md: clarified that the CLI now
  rejects missing/invalid --stats-type at the client side (won't even
  reach the server).

Verification:
- go build ./...                                    PASS
- go vet ./internal/helpers/... ./test/cli_compat/  PASS
- go test -run 'TestAttendanceSummary_should_(pass_stats_type|error_when_stats_type)' -v
                                                    PASS (4/4)
- dws attendance summary --help                     new layout shown

---------

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-07 10:50:24 +08:00
ybc❤️zyrand猷诺 a652b90fd4 fix: resolve cross-product tool routing collision (issue #219) (#220)
When two MCP servers register tools with the same name (e.g. both drive
and doc have 'create_folder'), the tool-level endpoint map uses
last-writer-wins, causing invocations to route to the wrong server.

Fix: swap Priority 1 (product-level) and Priority 2 (tool-level) in
directRuntimeEndpoint so that when the caller already knows the
productID, the product endpoint is authoritative. Tool-level lookup
remains as a fallback for cases where productID is empty.

This fixes 'dws drive mkdir' and 'dws drive download' being silently
routed to the doc MCP server instead of the drive MCP server.

Closes #219

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-05 16:21:51 +08:00
修雨 7a868ddf39 docs(help): document --contents.key=field_name and mark required flags (#106 #107) (#217)
- chat message send-by-bot --robot-code/--title/--text: append (必填) marker
  (RunE already enforces these as required; help text was missing the tag,
  causing downstream MCP wrappers to generate schemas with required fields
  missing).
- report create --contents: description appended "key must exactly equal the
  template field_name (look it up via report template detail --name <template>)";
  Long now warns about the API's SYSTEM_ERROR on key mismatch; Examples
  rewritten as a two-step pipeline (template detail → create).

Closes #106
Closes #107
2026-05-04 17:57:23 +08:00
ybc❤️zyrand猷诺 89d7c5f11b docs: clarify message list pagination to prevent nextCursor misuse (fixes #195) (#218)
* docs: clarify message list pagination to prevent nextCursor misuse (fixes #195)

- Add detailed pagination walkthrough section for 'message list' in chat.md
- Add comparison table distinguishing 'message list' (--time with createTime)
  from 'message list-all' (--cursor with nextCursor)
- Add warning about common error: using nextCursor as --time value
  causes infinite loop returning same page

* docs: fix --time required label and add --forward misuse warning (fixes #195)

---------

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-04 16:38:09 +08:00
ybc❤️zyrand猷诺 efb61cae02 fix: restore unconditional MCP re-fetch in login to prevent stale clientId errors (#213)
PR #184 merge conflict resolution inadvertently introduced a conditional
branch that preserved stale clientID from previous login sessions instead
of always re-fetching from MCP server. This caused exchangeCode() to use
direct DingTalk API mode (which requires clientSecret) instead of MCP
proxy mode, resulting in 'clientId或者clientSecret错误' errors.

This commit restores the original PR #157 logic: both DeviceFlowProvider
and OAuthProvider unconditionally call resetCredentialState() followed by
FetchClientIDFromMCP() + SetClientIDFromMCP(), ensuring exchangeCode()
always uses the MCP proxy path regardless of prior login state.

Root cause: the conditional branch treated any non-empty clientID (from
runtimeClientID or app.json) as a user-provided --client-id flag value,
skipping MCP re-fetch and leaving clientIDFromMCP=false after reset.

Affected scenarios:
- OAuth login → device flow login
- Any login → --force login
- New terminal with existing app.json → device flow login

Fixes regression introduced in PR #184 (commit 46192d6).
Restores fix from PR #157 (commit ad33a46).

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-04 09:26:25 +08:00
ybc❤️zyrand猷诺 fb88c6ace9 docs(todo): 增强待办命令帮助文本,补充字段语义说明 (#205)
基于实际 API 返回验证,所有描述均经过 dws 命令实测确认。

- todo task get: 新增 Long 说明
  · creatorId/executorIds/participantIds/modifierId 是待办内部短数字 ID
    (如 6380165826),非通讯录 userId (如 035551044606950179),已实测确认
  · 提示 creatorInfo/executorInfos/participantInfos 包含 name 属性可获取姓名
  · bizTag/source 返回 teambition 是引擎实现标识,已实测确认
  · tenantId 非 corpId,补充 tenantType 维度说明
- todo task list: 新增 Long 说明
  · 覆盖范围: 仅返回执行者维度待办,不含仅参与/仅创建的待办
  · 仅限个人待办,不含 OA 审批流/Teambition 项目任务
  · 自动分页说明
- todo task create: --executors flag 描述增强
  · 明确此处 userId 是通讯录 userId 并提示查询方式

Fixes #105

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-05-04 09:26:12 +08:00
ybc❤️zyrand猷诺 5258959a14 docs: update contact search examples from --keyword to --query (#209)
The --keyword flag was renamed to --query for contact user search,
contact dept search, and devdoc article search (as noted in CHANGELOG).
This commit updates all documentation references for the contact
commands. devdoc will be addressed in a follow-up PR.

Refs #105

Co-authored-by: 猷诺 <bicheng.ybc@alibaba-inc.com>
2026-04-30 16:32:34 +08:00
xianfeng wangandgithub-actions[bot] c515fc1001 1.0.19 CHANGELOG (#204)
* 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

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-30 14:36:58 +08:00
65 changed files with 3344 additions and 248 deletions
+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 52.8%"><title>coverage: 52.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">52.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">52.8%</text></g></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">54.2%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">54.2%</text></g></svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
+131
View File
@@ -4,6 +4,137 @@ 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.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`.
### 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.
- `PipelineStep` supports `type:"call"` (with optional `PollUntilField` / `PollUntilValue` / `PollIntervalSec` / `PollTimeoutSec` for polling loops) and `type:"download"` (resolves `DownloadURLField`, HTTP GETs the body, writes to the path from `OutputFlag`, infers filename for directory paths).
- Template language: `$flag.<name>` resolves a user CLI flag by alias; `$step.<idx>.<dotPath>` walks a prior step's response (works through wrapped MCP envelopes); literals pass through.
- `CLIFlagOverride.PipelineLocal` marks a flag as CLI-side only so `CollectBindings` skips it (value never reaches MCP params); the pipeline executor still reads it via `extractFlagValuesByAlias`.
- Download step emits machine-parseable plain-text lines (`jobId: <id>\n`, `downloadUrl: <url>\n`) alongside the standard JSON envelope, so shell pipelines and regex-based tests can extract key values without JSON parsing.
## [1.0.24] - 2026-05-09
Three small but user-visible safety/usability changes: the embedded distribution now refuses to self-upgrade, the `dws auth login` help text finally matches the actual default flow (loopback, not device), and the release workflow gains a manual fallback trigger.
### Changed
- **`dws upgrade` is blocked in embedded distributions** (#248) — when the CLI is shipped as an embedded asset (e.g. inside another product), `dws upgrade` would happily overwrite the host-managed binary. The upgrade entry point now detects the embedded build flag and exits early with a clear message; covered by `internal/app/upgrade_embedded_guard_test.go`.
### Docs
- **`dws auth login` help text reflects the real default** (#238, fixes #226) — the long help previously claimed "OAuth 设备流 (默认)", but the actual default starts a 127.0.0.1 loopback listener and only switches to device flow when `--device` is passed. SSH-into-headless-Linux users following the old text hit a dead end (remote-side 127.0.0.1 is unreachable from the local browser). Help and two `flagErrorWithSuggestions` messages in `root.go` are realigned: each method is named after its real flag (`OAuth Loopback 流 (默认)` / `OAuth 设备流 (--device)` / `直接提供 Token (--token)`), with an explicit `--device` example for SSH/headless. No behaviour change.
### CI
- **`workflow_dispatch` trigger added to release workflow as a fallback** (#261) — GitHub occasionally drops tag-push events; the release job can now be re-run manually against any tag ref without having to delete and re-push the tag.
## [1.0.23] - 2026-05-08
A single fix for HTTP proxy support across the CLI's custom HTTP transports. No behaviour changes elsewhere.
### Fixed
- **`HTTP_PROXY` / `HTTPS_PROXY` environment variables silently ignored by all custom transports** (#237, fixes #236) — the three custom `http.Transport` instances built by the CLI (`internal/transport/client.go` MCP transport, `internal/apiclient/client.go` DingTalk OpenAPI client, `internal/app/legacy.go` IPv4-forcing registry client) all set `DialContext` / `TLSClientConfig` / timeouts but omitted the `Proxy` field. Per Go's `net/http` contract, a non-nil Transport without an explicit `Proxy` means "no proxy" — env vars are silently ignored, breaking sandboxed or air-gapped deployments that route outbound through `HTTP_PROXY` / `HTTPS_PROXY`. All three transports now set `Proxy: http.ProxyFromEnvironment`.
### Tests
- Per-package regression test that pointer-compares the Transport's `Proxy` func against `http.ProxyFromEnvironment`, avoiding flakiness from Go's `envProxyOnce` memoisation when running alongside tests that read proxy env early. (#237)
## [1.0.22] - 2026-05-07
Two release-blocking bug fixes: `dws attendance summary` now exposes the server-required `--stats-type` flag (without it, every call returned C0002), and the install scripts finally populate `~/.hermes/skills/dws/` for users who already have Hermes.
### Fixed
- **`dws attendance summary` returned C0002 (统计类型错误) on every call** (#228, fixes #227) — the DingTalk MCP tool `get_attendance_summary` requires `statsType` at the business layer even though the schema marks it optional. The CLI did not expose any way to set it, so the command was 100% unusable. A new `--stats-type` flag (`week` / `month`) is now plumbed through to `QueryUserAttendVO.statsType`; the flag is documented as required in the long help, flag description, and `skills/references/products/attendance.md`.
- **Install scripts skipped `.hermes/skills/` when populating skill directories** (#221, fixes #188) — the `AGENT_DIRS` lists across `build/npm/install.js`, `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh` and the four upgrade-path mirrors (8 sources total once review feedback was addressed) did not include `.hermes/skills`, so users with Hermes installed were not getting `~/.hermes/skills/dws/` populated automatically. The existing parent-directory gate keeps this zero-side-effect for users without Hermes.
### Tests
- New `--stats-type` regression coverage in `test/cli_compat/attendance_test.go` — verifies `statsType` is written to `QueryUserAttendVO` when set to `month` or `week`, and is omitted when not provided. (#228)
## [1.0.21] - 2026-05-05
A single critical routing fix for `dws drive` commands. No new commands or behaviour changes elsewhere.
### Fixed
- **`dws drive mkdir` / `dws drive download` silently routed to the doc MCP server** (#220, fixes #219) — when two MCP servers register tools with the same name (e.g. both `drive` and `doc` expose `create_folder`), the tool-level endpoint map used last-writer-wins, so drive-side calls landed on the doc endpoint and returned mock-shaped responses (`success: true` with a fake `folderId`) without actually creating anything. `directRuntimeEndpoint` now resolves product-level first when the caller already knows the productID, and only falls back to the tool-level lookup when productID is empty. The wrong-server collision and the resulting "succeeded but didn't" behaviour are gone.
## [1.0.20] - 2026-05-04
Documentation polish and a login regression fix. No behaviour changes outside the login MCP refresh path.
### Fixed
- **Login no longer reuses stale `clientId` from an old MCP cache** (#213) — `dws login` now unconditionally re-fetches the MCP descriptor, so a previously cached client id can't keep producing auth errors after the server rotates it.
### Docs
- **`dws chat message list` pagination** (#218, fixes #195) — clarifies that `nextCursor` is opaque and must be passed back as `--cursor` exactly; warns against parsing or reusing it as an offset.
- **`dws contact search` examples** (#209) — switched from the removed `--keyword` flag to the current `--query`.
- **`dws todo` help text** (#205) — expanded field semantics so MCP wrappers generate accurate schemas.
- **`dws chat message send-by-bot` and `dws report create` help** (#217, #106, #107) — `--robot-code` / `--title` / `--text` now carry the `(必填)` marker; `report create --contents` documents the `key=field_name` requirement and rewrites examples as a `template detail → create` two-step pipeline.
- **CHANGELOG backfill for 1.0.19** (#204).
## [1.0.19] - 2026-04-30
Discovery hardening for edition overlays: `edition.SupplementServers` / `FallbackServers` hooks now consistently surface through the **runtime catalog loader**, not just the static command tree, so overlay products that live outside the Portal envelope (e.g. Wukong gray-release `conference`) resolve an endpoint on both the cold-cache and tool-not-in-catalog paths. Ships with per-edition cache partitioning to stop cross-edition disk-cache leakage, plus a small todo fix.
### Added
- **`pkg/config.EditionPartition(name)`** (#197) — returns the cache partition key for a given edition. Open-source core (`""` / `"open"`) keeps using `DefaultPartition` (`default/default`); every other edition gets its own namespace (`<edition>/default`), preventing cross-edition data leakage in the shared `~/.dws` disk cache. Lives in `pkg/config` as a leaf helper so `internal/cli`, `internal/app`, and `internal/cache` can all call it without risking import cycles.
- **`internal/editionmerge` shared package** (#197) — single source of truth for converting `edition.ServerInfo` into `market.ServerDescriptor` (`ToDescriptor`) and for merging `SupplementServers` / `FallbackServers` into a descriptor list. Both `internal/cli` (command tree) and `internal/app` (runtime catalog) now apply the edition hooks against the same discovery pipeline.
### Changed
- **`EnvironmentLoader.loadFromCache` honors `SupplementServers` even on empty registry** (#197) — when the Portal registry cache is missing or empty, the catalog loader still materialises the edition's `SupplementServers` as endpoint-only `discovery.RuntimeServer` entries (source: `edition_supplement`), so hardcoded overlay commands for supplement-only products can still resolve an endpoint via the catalog path. Previously `loadFromCache` short-circuited to an empty catalog whenever the registry snapshot was empty, silently dropping gray-release products.
- **Cache loader switches from `DefaultPartition` to `EditionPartition(edition.Get().Name)`** (#197) — the runtime catalog, registry snapshot, and tools snapshot are now partitioned per edition instead of all editions sharing `default/default`.
- **`loadFromCache` appends supplement servers alongside fresh-cache servers** (#197) — supplement entries whose `CLI.ID` / `Key` are already present in the cached registry are skipped, so the hook never shadows Portal-published servers; only new products are added.
- **`runtimeRunner.Run` falls through to `directRuntimeEndpoint` for supplement products** (#197) — when the catalog contains the product (e.g. supplied by `SupplementServers`) but the specific tool is not declared, the runner now trusts `directRuntimeEndpoint` to resolve a working endpoint for the tool before returning the explicit catalog-miss error. Supplement entries intentionally carry no tool list, so this is the path that makes overlay-only tools executable.
- **Legacy `mergeSupplementServers` / `fallbackToDescriptors` moved out of `internal/app/legacy.go`** (#197) — relocated into `internal/editionmerge` and reused by the catalog loader, eliminating the duplicate `edition.ServerInfo → market.ServerDescriptor` logic that previously only ran on the static command-tree path.
### Fixed
- **`dws todo task get` returns empty** (#202) — the helper was calling `query_todo_detail`, which is not a valid MCP tool and returns empty. Switched to `get_todo_detail` as declared in `discovery.json`, restoring correct task-detail behaviour.
- **Conference and other Wukong gray-release products miss endpoint on cold cache** (#197) — products registered only via `edition.SupplementServers` (not yet in the Portal envelope) now resolve an endpoint through the catalog path in both cold-start and tool-not-declared scenarios.
### Tests
- `internal/editionmerge/merge_test.go` — descriptor conversion + supplement/fallback merge semantics.
- `internal/cli/loader_partition_test.go` + `loader_supplement_test.go` — edition-partitioned cache reads and supplement hook surfacing from `loadFromCache` (including empty-registry cold path and existing-ID deduplication).
- `internal/app/legacy_wukong_partition_e2e_test.go` — end-to-end cache partition isolation for the Wukong edition.
- `internal/app/runner_supplement_fallback_test.go` — runner falls through to `directRuntimeEndpoint` when the tool isn't declared by a supplement-sourced catalog entry.
- `pkg/config/constants_test.go` — `EditionPartition` name handling (`""`, `"open"`, custom edition).
### Docs
- **CHANGELOG v1.0.18 rewrite** (#193) — previous release notes expanded to call out the PAT host-owned A-core flow, exit-code contract change (auth `4`, Discovery/cache/protocol `6`), `dws pat chmod` / `pat browser-policy` entry points, stderr-JSON classifier updates, and host-control metadata injection.
## [1.0.18] - 2026-04-28
Raw DingTalk OpenAPI access lands as a new `dws api` surface for both `api.dingtalk.com` and `oapi.dingtalk.com`, backed by app-level token caching and guarded host allowlists. PAT enters the host-owned **A-core** loop: agent hosts can own authorization UI through `DINGTALK_DWS_AGENTCODE`, parse single-line stderr JSON, call `dws pat chmod`, and replay the original command. Chat helper regressions are fixed, skill references are brought back in line with shipped commands, and the v1.0.17 Mail release notes are backfilled into README / CHANGELOG.
+6 -4
View File
@@ -207,7 +207,7 @@ dws is designed as an AI-native CLI. Complete [Installation](#installation) and
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
# Use --dry-run to preview operations (safe execution)
dws contact user search --keyword "engineering" --dry-run
dws contact user search --query "engineering" --dry-run
# Use --jq to extract precisely (save tokens)
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
@@ -332,7 +332,7 @@ Built-in pipeline engine that normalizes flag names, splits sticky arguments, an
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # auto-corrected to --base-id --table-id
# Sticky argument splitting
dws contact user search --keyword "engineering" --timeout30 # auto-split to --timeout 30
dws contact user search --query "engineering" --timeout30 # auto-split to --timeout 30
# Fuzzy flag name matching
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id -> --table-id
@@ -413,17 +413,19 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload |
| 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 |
| 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.
> **204 commands across 16 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) · `aiapp` (AI apps) · `live` (streaming)
</details>
+6 -4
View File
@@ -207,7 +207,7 @@ dws 是为 AI Agent 设计的 CLI 工具。请先完成[安装](#安装)和[开
dws todo task create --title "Review PR" --executors "<your-userId>" --yes
# 使用 --dry-run 预览操作(安全执行)
dws contact user search --keyword "张三" --dry-run
dws contact user search --query "张三" --dry-run
# 使用 --jq 精确提取(节省 token)
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
@@ -332,7 +332,7 @@ dws api GET /v1.0/microApp/allApps --jq '.agentId' # jq 过滤
dws aitable record query --baseId BASE_ID --tableId TABLE_ID # 自动纠正为 --base-id --table-id
# 粘连参数自动拆分
dws contact user search --keyword "张三" --timeout30 # 自动拆分为 --timeout 30
dws contact user search --query "张三" --timeout30 # 自动拆分为 --timeout 30
# 拼写错误模糊匹配
dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-id → --table-id
@@ -413,17 +413,19 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `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` | 搜索钉钉开放平台文档 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
> **14 个产品,163 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> **16 个产品,204 条命令。** 完整命令清单(带描述与使用场景):[`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`(视频会议)· `aiapp`(AI 应用)· `live`(直播)
</details>
+1
View File
@@ -53,6 +53,7 @@ __KEG_ONLY_LINE__
Pathname.new(File.join(Dir.home, ".kiro/skills/dws")),
Pathname.new(File.join(Dir.home, ".trae/skills/dws")),
Pathname.new(File.join(Dir.home, ".openclaw/skills/dws")),
Pathname.new(File.join(Dir.home, ".hermes/skills/dws")),
]
targets.each_with_index do |dest, index|
+1
View File
@@ -22,6 +22,7 @@ const AGENT_DIRS = [
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
];
const PLATFORM_MAP = {
+5 -4
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 / 退出码
@@ -30,9 +31,9 @@ With `-f json`, error responses include structured payloads: `category`, `reason
## Output Formats / 输出格式
```bash
dws contact user search --keyword "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --keyword "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --keyword "Alice" -f raw # Raw API response / 原始响应
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
```
@@ -45,7 +46,7 @@ dws todo task list --dry-run # Preview MCP call without executing / 预览但
## Output to File / 输出到文件
```bash
dws contact user search --keyword "Alice" -o result.json
dws contact user search --query "Alice" -o result.json
```
## Schema Introspection / Schema 查询
+2
View File
@@ -210,6 +210,8 @@ func NormalisePath(path, baseURL string) string {
// defaultTransport returns a tuned http.Transport matching the project conventions.
func defaultTransport() *http.Transport {
return &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
+43
View File
@@ -0,0 +1,43 @@
// 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 apiclient
import (
"net/http"
"reflect"
"testing"
)
// TestDefaultTransportHonoursHTTPProxyEnv is the regression guard for #236
// on the apiclient transport. Same rationale as transport/proxy_env_test.go:
// a custom Transport without an explicit Proxy field silently bypasses
// HTTP_PROXY/HTTPS_PROXY.
//
// We pointer-compare against http.ProxyFromEnvironment instead of invoking
// it, because http.ProxyFromEnvironment memoises the env on first call;
// other tests that read proxy env early would make a value-based assertion
// flaky.
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
tr := defaultTransport()
if tr.Proxy == nil {
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+8 -3
View File
@@ -68,16 +68,21 @@ func newAuthLoginCommand() *cobra.Command {
Long: `登录钉钉并获取认证凭证。
支持的登录方式:
- OAuth 设备流 (默认): 通过钉钉扫码授权登录
- 直接提供 Token: 通过 --token 参数传入已有 token
- OAuth Loopback 流 (默认): 本机自动起 127.0.0.1 监听接收回调,浏览器授权后自动完成
- OAuth 设备流 (--device): 显示 user_code + 短 URL,适合 SSH 远程 / 容器 / 无头环境
- 直接提供 Token (--token): 跳过授权,使用已有 token
不支持的登录方式:
- 邮箱/密码登录
- 手机号/验证码登录
- 应用凭证 (AppKey/AppSecret) 直接登录
注意: SSH 远程或无头环境(无本地浏览器可访问远端的 127.0.0.1)请使用 --device,
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
示例:
dws auth login # 扫码登录
dws auth login # 本机扫码登录 (loopback 流)
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
dws auth login --force # 强制重新登录 (忽略缓存 token)
dws auth login --token xxx # 使用指定 token`,
DisableAutoGenTag: true,
+15 -8
View File
@@ -227,14 +227,11 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
te := dynamicToolEndpoints
dynamicMu.RUnlock()
// Priority 1: tool-level endpoint (resolves multi-endpoint products).
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
if endpoint, ok := te[tool]; ok {
return endpoint, true
}
}
// Priority 2: product-level endpoint.
// Priority 1: product-level endpoint.
// When the caller already knows the productID (e.g. "drive"), the product
// endpoint is authoritative. This prevents cross-product tool name
// collisions (e.g. both "drive" and "doc" register "create_folder") from
// routing the request to the wrong MCP server. See issue #219.
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == "" {
continue
@@ -246,6 +243,16 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
}
}
// Priority 2: tool-level endpoint (fallback for unknown productID).
// This path is used when the caller does not know the productID but has a
// tool name, e.g. in helper invocations or plugin routes where only the
// tool name is available.
if tool := strings.TrimSpace(toolName); tool != "" && te != nil {
if endpoint, ok := te[tool]; ok {
return endpoint, true
}
}
// Priority 3: built-in PAT fallback for cold-start paths that run before
// discovery/plugin registration has populated the dynamic registry.
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
@@ -181,3 +181,114 @@ func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.
})
}
}
// --- Issue #219 regression tests: cross-product tool name collision ---
//
// When two different products register tools with the same name (e.g. drive
// and doc both have "create_folder"), the product-level endpoint must win
// when the caller already knows the productID. Otherwise the tool-level map
// (last-writer-wins) routes the invocation to the wrong MCP server.
const (
testDriveEndpoint = "https://mcp-gw.dingtalk.com/server/drive-hash"
testDocEndpoint = "https://mcp-gw.dingtalk.com/server/doc-hash"
)
func driveDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testDriveEndpoint,
CLI: market.CLIOverlay{
ID: "drive",
Command: "drive",
ToolOverrides: map[string]market.CLIToolOverride{
"create_folder": {CLIName: "mkdir"},
"list_files": {CLIName: "list"},
"download_file": {CLIName: "download"},
"get_upload_info": {CLIName: "upload-info"},
},
},
}
}
func docDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testDocEndpoint,
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"create_folder": {CLIName: "create", Group: "folder"},
"download_file": {CLIName: "download"},
"search_documents": {CLIName: "search"},
"list_nodes": {CLIName: "list"},
},
},
}
}
// TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel verifies
// that when productID is known and has a registered endpoint, the product-level
// endpoint is used even if the tool-level map points to a different server
// (due to same-name tool collision). This is the core fix for issue #219.
func TestDirectRuntimeEndpoint_ProductLevelWinsOverConflictingToolLevel(t *testing.T) {
tests := []struct {
name string
servers []market.ServerDescriptor
}{
{
name: "drive first, doc second",
servers: []market.ServerDescriptor{driveDescriptor(), docDescriptor()},
},
{
name: "doc first, drive second",
servers: []market.ServerDescriptor{docDescriptor(), driveDescriptor()},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers(tc.servers)
// Drive tools must always route to drive's endpoint regardless of
// registration order — productID "drive" is known.
assertEndpoint(t, "drive", "create_folder", testDriveEndpoint)
assertEndpoint(t, "drive", "download_file", testDriveEndpoint)
assertEndpoint(t, "drive", "list_files", testDriveEndpoint)
assertEndpoint(t, "drive", "get_upload_info", testDriveEndpoint)
// Doc tools must always route to doc's endpoint.
assertEndpoint(t, "doc", "create_folder", testDocEndpoint)
assertEndpoint(t, "doc", "download_file", testDocEndpoint)
assertEndpoint(t, "doc", "search_documents", testDocEndpoint)
assertEndpoint(t, "doc", "list_nodes", testDocEndpoint)
// Product-level fallback (no tool name) still works.
assertEndpoint(t, "drive", "", testDriveEndpoint)
assertEndpoint(t, "doc", "", testDocEndpoint)
})
}
}
// 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).
func TestDirectRuntimeEndpoint_ToolLevelFallbackWhenProductUnknown(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers([]market.ServerDescriptor{driveDescriptor(), docDescriptor()})
// When productID is empty, tool-level endpoint is the only option.
// The actual endpoint depends on registration order (last-writer-wins),
// but the lookup must succeed.
endpoint, ok := directRuntimeEndpoint("", "create_folder")
if !ok {
t.Fatal("directRuntimeEndpoint(\"\", \"create_folder\") returned ok=false, want ok=true")
}
if endpoint != testDriveEndpoint && endpoint != testDocEndpoint {
t.Fatalf("directRuntimeEndpoint(\"\", \"create_folder\") = %q, want one of drive/doc endpoints", endpoint)
}
// Unique tools (no collision) still resolve via tool-level.
assertEndpoint(t, "", "search_documents", testDocEndpoint)
assertEndpoint(t, "", "get_upload_info", testDriveEndpoint)
}
+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 ""
}
+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")
+2
View File
@@ -464,6 +464,8 @@ func ipv4HTTPClient(timeout time.Duration) *http.Client {
return &http.Client{
Timeout: timeout,
Transport: &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp4", addr)
},
+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()
}
+96 -3
View File
@@ -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{
@@ -920,8 +921,26 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !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)
}
}
+50
View File
@@ -0,0 +1,50 @@
// 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 (
"net/http"
"reflect"
"testing"
"time"
)
// TestIPv4HTTPClientHonoursHTTPProxyEnv guards the fix for #236 on the
// IPv4-forcing client used by the legacy registry / discovery path. The
// custom Transport overrides DialContext to force IPv4 — without an
// explicit Proxy field it would also drop env-var proxy support.
//
// We can't reliably invoke tr.Proxy(req) here because http.ProxyFromEnvironment
// memoises the env vars on first call (Go's envProxyOnce); ordering with other
// tests that read proxy env early would make this flaky. Asserting that the
// Transport's Proxy func points at http.ProxyFromEnvironment is sufficient to
// catch the regression — the runtime takes care of reading HTTP_PROXY/HTTPS_PROXY
// at process boot.
func TestIPv4HTTPClientHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
client := ipv4HTTPClient(5 * time.Second)
tr, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("ipv4HTTPClient transport is %T, want *http.Transport", client.Transport)
}
if tr.Proxy == nil {
t.Fatal("ipv4HTTPClient transport.Proxy is nil — HTTP_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("ipv4HTTPClient transport.Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+2 -2
View File
@@ -120,8 +120,8 @@ func flagErrorWithSuggestions(cmd *cobra.Command, err error) error {
// Common flag aliases and suggestions
suggestions := map[string]string{
"--json": "提示: 请使用 --format json 或 -f json 来输出 JSON 格式",
"--method": "提示: dws auth login 默认使用 OAuth 设备流登录,无需指定 --method",
"--device-flow": "提示: dws auth login 默认已使用设备流,无需 --device-flow 参数",
"--method": "提示: dws auth login 默认使用 OAuth loopback 流;SSH/无头环境请加 --device 走设备流",
"--device-flow": "提示: 设备流的标志名是 --device(不是 --device-flow),SSH/无头环境登录请用 dws auth login --device",
"--email": "提示: dws 不支持邮箱/密码登录,请使用 dws auth login 进行扫码登录",
"--code": "提示: dws 不支持验证码登录,请使用 dws auth login 进行扫码登录",
"--corp-id": "提示: corp-id 会在登录时自动获取,无需手动指定",
+11
View File
@@ -364,6 +364,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")
+9
View File
@@ -14,6 +14,7 @@ import (
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
@@ -57,6 +58,14 @@ func newUpgradeCommand() *cobra.Command {
dws upgrade -y # 跳过确认直接升级`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if h := edition.Get(); h != nil && h.IsEmbedded {
name := h.Name
if name == "" {
name = "embedded"
}
return fmt.Errorf("当前运行在嵌入模式(%s),dws upgrade 已禁用;请通过宿主完成升级", name)
}
yes, _ := cmd.Flags().GetBool("yes")
format := resolveUpgradeFormat(cmd)
@@ -0,0 +1,69 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package app
import (
"bytes"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestUpgradeCommand_BlockedInEmbeddedMode(t *testing.T) {
prev := edition.Get()
edition.Override(&edition.Hooks{IsEmbedded: true, Name: "embedded"})
t.Cleanup(func() { edition.Override(prev) })
cases := []struct {
name string
args []string
}{
{"check", []string{"--check"}},
{"list", []string{"--list"}},
{"rollback", []string{"--rollback"}},
{"plain", []string{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := newUpgradeCommand()
var out, errBuf bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errBuf)
cmd.SetArgs(tc.args)
err := cmd.Execute()
if err == nil {
t.Fatalf("upgrade %v in embedded mode must return error, got nil", tc.args)
}
msg := err.Error()
if !strings.Contains(msg, "嵌入模式") {
t.Errorf("error message should mention 嵌入模式, got: %q", msg)
}
if !strings.Contains(msg, "embedded") {
t.Errorf("error message should include edition name, got: %q", msg)
}
if !strings.Contains(msg, "dws upgrade") {
t.Errorf("error message should reference dws upgrade for clarity, got: %q", msg)
}
})
}
}
func TestUpgradeCommand_NotBlockedInOpenSourceMode(t *testing.T) {
prev := edition.Get()
edition.Override(&edition.Hooks{IsEmbedded: false, Name: "open"})
t.Cleanup(func() { edition.Override(prev) })
cmd := newUpgradeCommand()
var out, errBuf bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&errBuf)
cmd.SetArgs([]string{"--check"})
err := cmd.Execute()
if err != nil && strings.Contains(err.Error(), "嵌入模式") {
t.Errorf("open-source mode must not be blocked by embedded guard, got: %v", err)
}
}
+14 -23
View File
@@ -161,31 +161,22 @@ func (p *DeviceFlowProvider) resetCredentialState() {
}
func (p *DeviceFlowProvider) Login(ctx context.Context) (*TokenData, error) {
// Defensive reset: clear stale credential state from previous login methods,
// but preserve user-provided --client-id if present.
userClientID := p.clientID
// Defensive reset: clear any stale credential state from previous login
// methods (OAuth scan, PAT, etc.) so we always re-fetch from MCP.
// This ensures --device login works regardless of what app.json contains.
p.resetCredentialState()
if userClientID != "" && userClientID != DefaultClientID {
// User provided --client-id flag: use it directly, skip MCP fetch.
p.clientID = userClientID
if p.logger != nil {
p.logger.Debug("using user-provided client ID, skipping MCP fetch", "clientID", userClientID)
}
} else {
// No user-provided client ID: fetch from MCP server.
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
}
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (device flow always re-fetches)")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
}
const maxAttempts = 3
+14 -23
View File
@@ -109,31 +109,22 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
}
// Fall through: full browser OAuth flow.
// Defensive reset: clear stale credential state from previous login methods,
// but preserve user-provided --client-id if present.
userClientID := p.clientID
// Defensive reset: clear any stale credential state from previous login
// methods so we always re-fetch clientID from MCP. This ensures
// --force login works regardless of what app.json contains.
p.resetCredentialState()
if userClientID != "" && userClientID != DefaultClientID {
// User provided --client-id flag: use it directly, skip MCP fetch.
p.clientID = userClientID
if p.logger != nil {
p.logger.Debug("using user-provided client ID, skipping MCP fetch", "clientID", userClientID)
}
} else {
// No user-provided client ID: fetch from MCP server.
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
}
if p.logger != nil {
p.logger.Debug("fetching client ID from MCP server (OAuth flow always re-fetches)")
}
mcpClientID, mcpErr := FetchClientIDFromMCP(ctx)
if mcpErr != nil {
return nil, fmt.Errorf("%s: %w", i18n.T("获取 Client ID 失败"), mcpErr)
}
p.clientID = mcpClientID
SetClientIDFromMCP(mcpClientID)
if p.logger != nil {
p.logger.Debug("fetched client ID from MCP server", "clientID", mcpClientID)
}
// Find a free port for the callback server.
+22 -8
View File
@@ -163,9 +163,12 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
}
route := Route{
Use: cliName,
Short: short,
Long: long,
Use: cliName,
// CLIAliases register additional cobra command aliases for the
// same MCP tool. Empty / nil means no extra names.
Aliases: append([]string(nil), override.CLIAliases...),
Short: short,
Long: long,
// Preserve left-side indentation: cobra's Examples template
// renders {{.Example}} verbatim, and hardcoded helper commands
// rely on a 2-space prefix to look indented under "Examples:".
@@ -176,7 +179,12 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
CanonicalProduct: canonicalProduct,
Tool: toolName,
},
Bindings: bindings,
Bindings: bindings,
// §pipeline: when the envelope declares a multi-step
// orchestration, NewDirectCommand reroutes RunE into the
// pipeline executor instead of the single-tool flow. The
// CLIName / Group / Flags surface above still applies.
Pipeline: append([]market.PipelineStep(nil), override.Pipeline...),
Normalizer: normalizer,
}
@@ -612,10 +620,16 @@ func buildOverrideBindings(override market.CLIToolOverride) ([]FlagBinding, Norm
binding := FlagBinding{
FlagName: flagName,
Aliases: extraAliases,
Short: strings.TrimSpace(flagOverride.Shorthand),
Property: paramName,
Kind: kindFromTypeName(flagOverride.Type),
Usage: usage,
// §pipeline: PipelineLocal flags (e.g. `--output` in the
// sheet export pipeline) are CLI-side only — they appear in
// --help and are bindable, but CollectBindings skips them so
// the value never reaches MCP params. The pipeline executor
// reads them via extractFlagValuesByAlias.
PipelineLocal: flagOverride.PipelineLocal,
Short: strings.TrimSpace(flagOverride.Shorthand),
Property: paramName,
Kind: kindFromTypeName(flagOverride.Type),
Usage: usage,
// §P1: Required is preserved for positional bindings too. For
// pure positional, cobra arity (MinimumNArgs) enforces presence
// at parse time. For dual-mode positional (positional + alias),
+441
View File
@@ -0,0 +1,441 @@
// 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 compat — pipeline executor for CLIToolOverride.Pipeline.
//
// A pipeline turns a single CLI command into an ordered sequence of MCP
// tool calls plus optional HTTP-download sinks, declared entirely in the
// envelope JSON. Use cases:
//
// 1. submit-job + poll-status + download-result patterns (the canonical
// example: `dws sheet export --node X --output PATH` calls
// submit_export_job → query_export_job (poll until status=done) →
// HTTP GET downloadUrl → write to PATH).
// 2. compose-then-update flows where step 2's args reference step 1's
// response.
//
// Templates supported in PipelineStep.Args / DownloadURLField:
//
// $flag.<aliasName> — value of the user's CLI flag whose alias
// equals <aliasName>
// $step.<idx>.<dotPath> — field from a prior step's response
// literal string — passed through unchanged
//
// Limitations (intentional, to keep the executor small):
// - No conditional branching: steps run unconditionally in order.
// - No retry-on-error: the pipeline aborts on the first runner error.
// - PollUntil compares as strings; numeric/boolean comparisons stringify.
// - Download step uses the standard library net/http with no custom
// timeout (relies on the user's Ctrl-C).
package compat
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
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/market"
)
// pipelineCtx carries flag values + accumulated step responses through
// the executor. Unexported because callers always interact via runPipeline.
type pipelineCtx struct {
flags map[string]string
stepOutputs []map[string]any
}
// runPipeline executes route.Pipeline against runner, returning the last
// "call"-type step's response (or a synthesized success payload if the
// pipeline ends with a "download" step). The map is the shape returned to
// the user via the standard output formatter.
func runPipeline(
ctx context.Context,
cmd *cobra.Command,
runner executor.Runner,
route Route,
flagValues map[string]string,
) (map[string]any, error) {
pctx := &pipelineCtx{
flags: flagValues,
stepOutputs: make([]map[string]any, 0, len(route.Pipeline)),
}
var lastCallResponse map[string]any
for i, step := range route.Pipeline {
stepType := strings.TrimSpace(step.Type)
if stepType == "" {
stepType = "call"
}
switch stepType {
case "call":
resp, err := executePipelineCall(ctx, runner, route, step, pctx)
if err != nil {
return nil, fmt.Errorf("pipeline step %d (%s): %w", i, step.Tool, err)
}
pctx.stepOutputs = append(pctx.stepOutputs, resp)
lastCallResponse = resp
case "download":
resp, err := executePipelineDownload(cmd, step, pctx)
if err != nil {
return nil, fmt.Errorf("pipeline step %d (download): %w", i, err)
}
pctx.stepOutputs = append(pctx.stepOutputs, resp)
default:
return nil, apperrors.NewValidation(
fmt.Sprintf("pipeline step %d: unsupported type %q (allowed: call, download)", i, stepType),
)
}
}
if lastCallResponse != nil {
return lastCallResponse, nil
}
return map[string]any{"success": true}, nil
}
// executePipelineCall resolves args templates, then either polls or fires
// a single MCP tool invocation via runner. PollUntilField + PollUntilValue
// non-empty enable polling.
func executePipelineCall(
ctx context.Context,
runner executor.Runner,
route Route,
step market.PipelineStep,
pctx *pipelineCtx,
) (map[string]any, error) {
if strings.TrimSpace(step.Tool) == "" {
return nil, apperrors.NewValidation("pipeline call step requires non-empty `tool`")
}
args, err := resolveArgs(step.Args, pctx)
if err != nil {
return nil, err
}
invoke := func() (map[string]any, error) {
invocation := executor.NewCompatibilityInvocation(
route.Use,
route.Target.CanonicalProduct,
step.Tool,
args,
)
result, err := runner.Run(ctx, invocation)
if err != nil {
return nil, err
}
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.
if errCode := getDotPath(result.Response, "content.errorCode"); errCode != nil && fmt.Sprint(errCode) != "" {
msg := getDotPath(result.Response, "content.errorMessage")
return nil, apperrors.NewValidation(fmt.Sprintf(
"%s rejected: %s — %v", step.Tool, errCode, msg,
))
}
return result.Response, nil
}
if strings.TrimSpace(step.PollUntilField) == "" {
return invoke()
}
// Polling loop.
interval := time.Duration(step.PollIntervalSec) * time.Second
if interval <= 0 {
interval = 2 * time.Second
}
timeoutSec := step.PollTimeoutSec
if timeoutSec <= 0 {
timeoutSec = 300
}
deadline := time.Now().Add(time.Duration(timeoutSec) * time.Second)
for {
resp, err := invoke()
if err != nil {
return nil, err
}
actual := getDotPath(resp, step.PollUntilField)
if actual != nil && fmt.Sprint(actual) == step.PollUntilValue {
return resp, nil
}
if time.Now().After(deadline) {
return nil, apperrors.NewValidation(fmt.Sprintf(
"pipeline poll timeout after %ds: field %q never reached value %q (last seen: %v)",
timeoutSec, step.PollUntilField, step.PollUntilValue, actual,
))
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(interval):
}
}
}
// executePipelineDownload resolves the URL template, fetches the body via
// HTTP GET, and writes it to the path supplied by OutputFlag's user value.
// Empty output path → print URL to stdout (terminal-friendly mode).
func executePipelineDownload(
cmd *cobra.Command,
step market.PipelineStep,
pctx *pipelineCtx,
) (map[string]any, error) {
urlAny, err := resolveTemplate(step.DownloadURLField, pctx)
if err != nil {
return nil, err
}
urlStr := strings.TrimSpace(fmt.Sprint(urlAny))
if urlStr == "" {
return nil, apperrors.NewValidation(fmt.Sprintf(
"pipeline download: URL template %q resolved to empty value",
step.DownloadURLField,
))
}
outputPath := strings.TrimSpace(pctx.flags[step.OutputFlag])
jobID := fmt.Sprint(inferJobIDFromContext(pctx))
// Always print machine-parseable "key: value" lines. Tests and shell
// pipelines that consume the pipeline output (regex / awk) rely on
// this exact format. The structured JSON output follows via
// output.WriteCommandPayload, so AI / SDK callers still get a typed
// response.
if jobID != "" {
fmt.Fprintf(cmd.OutOrStdout(), "jobId: %s\n", jobID)
}
fmt.Fprintf(cmd.OutOrStdout(), "downloadUrl: %s\n", urlStr)
if outputPath == "" {
return map[string]any{
"success": true,
"downloadUrl": urlStr,
"jobId": jobID,
}, nil
}
// If outputPath is a directory, infer filename from URL basename.
if info, statErr := os.Stat(outputPath); statErr == nil && info.IsDir() {
filename := inferFilenameFromURL(urlStr)
if filename == "" {
filename = fmt.Sprintf("export_%d", time.Now().Unix())
}
outputPath = filepath.Join(outputPath, filename)
}
resp, err := http.Get(urlStr) //nolint:gosec // user-supplied URL via MCP discovery is expected
if err != nil {
return nil, fmt.Errorf("HTTP GET %s: %w", urlStr, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("HTTP GET %s: status %d", urlStr, resp.StatusCode)
}
out, err := os.Create(outputPath)
if err != nil {
return nil, fmt.Errorf("create %s: %w", outputPath, err)
}
defer out.Close()
written, err := io.Copy(out, resp.Body)
if err != nil {
return nil, fmt.Errorf("write %s: %w", outputPath, err)
}
fmt.Fprintf(cmd.OutOrStdout(), "导出完成: %s (%d bytes)\n", outputPath, written)
return map[string]any{
"success": true,
"downloadUrl": urlStr,
"jobId": jobID,
"output": outputPath,
"size": written,
}, nil
}
// resolveArgs applies resolveTemplate to every value in the map.
func resolveArgs(args map[string]string, pctx *pipelineCtx) (map[string]any, error) {
out := make(map[string]any, len(args))
for k, tmpl := range args {
v, err := resolveTemplate(tmpl, pctx)
if err != nil {
return nil, fmt.Errorf("arg %q: %w", k, err)
}
out[k] = v
}
return out, nil
}
// resolveTemplate evaluates a single template string. Returns the literal
// when input does not start with '$'.
func resolveTemplate(tmpl string, pctx *pipelineCtx) (any, error) {
s := strings.TrimSpace(tmpl)
if !strings.HasPrefix(s, "$") {
return s, nil
}
// Split on the first dot: head ("$flag" / "$step") + tail (rest).
dot := strings.Index(s, ".")
if dot <= 0 || dot == len(s)-1 {
return nil, apperrors.NewValidation(fmt.Sprintf("malformed template %q (expected $flag.<name> or $step.<idx>.<path>)", tmpl))
}
head := s[:dot]
tail := s[dot+1:]
switch head {
case "$flag":
// tail is a flag alias name (no nested path supported)
return pctx.flags[tail], nil
case "$step":
// tail format: <idx>.<dotPath>
secondDot := strings.Index(tail, ".")
if secondDot <= 0 || secondDot == len(tail)-1 {
return nil, apperrors.NewValidation(fmt.Sprintf("malformed $step template %q (expected $step.<idx>.<dotPath>)", tmpl))
}
idxStr := tail[:secondDot]
dotPath := tail[secondDot+1:]
idx, err := strconv.Atoi(idxStr)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q has non-numeric index", tmpl))
}
if idx < 0 || idx >= len(pctx.stepOutputs) {
return nil, apperrors.NewValidation(fmt.Sprintf("$step template %q references step %d, but only %d step(s) executed so far", tmpl, idx, len(pctx.stepOutputs)))
}
return getDotPath(pctx.stepOutputs[idx], dotPath), nil
default:
return nil, apperrors.NewValidation(fmt.Sprintf("unknown template prefix %q in %q (allowed: $flag, $step)", head, tmpl))
}
}
// getDotPath walks dotPath through nested map[string]any. Returns nil if
// any segment is missing or the value isn't a map at an intermediate step.
func getDotPath(m map[string]any, dotPath string) any {
parts := strings.Split(dotPath, ".")
var current any = m
for _, p := range parts {
nested, ok := current.(map[string]any)
if !ok {
return nil
}
current = nested[p]
}
return current
}
// inferFilenameFromURL extracts the basename from a URL's path component,
// stripping query string + fragment. Returns "" if the URL doesn't parse
// or has no useful basename.
func inferFilenameFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
base := path.Base(u.Path)
if base == "" || base == "/" || base == "." {
return ""
}
return base
}
// inferJobIDFromContext walks prior step outputs looking for a `jobId`
// field at top level or one level under common MCP wrappers ("content" /
// "result"), so the synthetic download response can echo it back to the
// user. Returns "" when no jobId is present anywhere in prior responses.
func inferJobIDFromContext(pctx *pipelineCtx) any {
candidates := []string{"jobId", "content.jobId", "result.jobId"}
for i := len(pctx.stepOutputs) - 1; i >= 0; i-- {
for _, p := range candidates {
if v := getDotPath(pctx.stepOutputs[i], p); v != nil && fmt.Sprint(v) != "" {
return v
}
}
}
return ""
}
// extractFlagValuesByAlias reads the cobra command's flag values keyed by
// the FlagBinding's primary CLI flag name, so the pipeline executor can
// resolve "$flag.<name>" templates in O(1). Pipeline-local flags are
// always included (they are the whole point of the lookup).
//
// Note on key choice: buildOverrideBindings populates FlagName from the
// envelope's `alias` field (or kebab-case of the MCP property name when
// alias is empty), and leaves the FlagBinding.Alias struct field empty —
// so $flag templates reference the user-visible CLI flag name, e.g.
// "$flag.node" matches `--node`.
func extractFlagValuesByAlias(cmd *cobra.Command, bindings []FlagBinding) map[string]string {
flags := cmd.Flags()
out := make(map[string]string, len(bindings))
for _, b := range bindings {
primary := strings.TrimSpace(b.FlagName)
if primary == "" {
primary = strings.TrimSpace(b.Alias)
}
if primary == "" {
continue
}
// Try the primary flag name first, then any of the extra aliases.
// Whichever the user actually set wins; if none was set, the
// cobra-level default value is returned.
candidates := make([]string, 0, 2+len(b.Aliases))
candidates = append(candidates, primary)
if a := strings.TrimSpace(b.Alias); a != "" && a != primary {
candidates = append(candidates, a)
}
for _, a := range b.Aliases {
if a = strings.TrimSpace(a); a != "" {
candidates = append(candidates, a)
}
}
var value string
for _, c := range candidates {
f := flags.Lookup(c)
if f == nil {
continue
}
value = f.Value.String()
if f.Changed {
break
}
}
out[primary] = value
}
return out
}
// jsonRoundTrip marshals + unmarshals so user-provided strings come out
// the other side as Go primitives where appropriate. Unused for now —
// the resolveTemplate path returns strings as-is to keep the contract
// simple; tools that need JSON-shaped values can use the existing
// `transform: "json_parse_strict"` on the relevant flag (post-pipeline
// composition is not in scope for the MVP).
var _ = json.Unmarshal
+58 -14
View File
@@ -28,6 +28,7 @@ import (
"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/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert"
"github.com/spf13/cobra"
@@ -60,12 +61,16 @@ type FlagBinding struct {
// parameter. Any of them being set satisfies Required, and the value
// is resolved via firstChangedFlag(FlagName, Alias, Aliases...).
// Mirrors cmdutil.ValidateRequiredFlagWithAliases / FlagOrFallback.
Aliases []string
Short string
Property string
Kind ValueKind
Usage string
Required bool
Aliases []string
// PipelineLocal, when true, marks this binding as CLI-side only — its
// value is consumed by the pipeline executor (e.g. as an HTTP
// download destination) and NOT forwarded to any MCP tool's params.
PipelineLocal bool
Short string
Property string
Kind ValueKind
Usage string
Required bool
// Default is the cobra-level flag default value as a string. Parsed
// into the Kind-appropriate primitive at registration time. Empty
// string keeps the existing zero-value default. This only affects
@@ -82,14 +87,19 @@ type FlagBinding struct {
type Normalizer func(cmd *cobra.Command, params map[string]any) error
type Route struct {
Use string
Aliases []string
Short string
Long string
Example string
Hidden bool
Target Target
Bindings []FlagBinding
Use string
Aliases []string
Short string
Long string
Example string
Hidden bool
Target Target
Bindings []FlagBinding
// Pipeline, when non-empty, replaces the single-tool dispatch with a
// multi-step orchestration. NewDirectCommand sees this and wires the
// pipeline executor into RunE instead of the standard
// invoke-then-output flow. See internal/compat/pipeline.go.
Pipeline []market.PipelineStep
Normalizer Normalizer
// OutputTransform, when non-nil, post-processes the MCP response payload
// (rename / drop / columns) before the formatter emits it. Wired up from
@@ -277,6 +287,33 @@ func NewDirectCommand(route Route, runner executor.Runner) *cobra.Command {
delete(params, "_blocked")
}
// §pipeline: when the override declares a multi-step pipeline,
// dispatch via the pipeline executor instead of the single-tool
// invoke-then-output flow. The executor reads flag values by
// alias (so $flag.<alias> templates resolve), walks each step,
// handles polling + downloads, and returns the last "call"
// step's response as the payload to the formatter.
if len(route.Pipeline) > 0 {
flagValues := extractFlagValuesByAlias(cmd, route.Bindings)
resp, err := runPipeline(cmd.Context(), cmd, runner, route, flagValues)
if err != nil {
return err
}
result := executor.Result{
Invocation: executor.NewCompatibilityInvocation(
cobracmd.LegacyCommandPath(cmd),
route.Target.CanonicalProduct,
"pipeline",
params,
),
Response: resp,
}
if route.OutputTransform != nil && result.Response != nil {
result.Response = route.OutputTransform(result.Response)
}
return output.WriteCommandPayload(cmd, result, output.FormatJSON)
}
invocation := executor.NewCompatibilityInvocation(
cobracmd.LegacyCommandPath(cmd),
route.Target.CanonicalProduct,
@@ -706,6 +743,13 @@ func CollectBindings(cmd *cobra.Command, bindings []FlagBinding, existing map[st
}
params := make(map[string]any)
for _, binding := range bindings {
// Pipeline-local flags exist purely for the pipeline executor
// (e.g. --output destination paths) and must never be forwarded
// to MCP tools as params, otherwise the upstream API would
// either reject the unknown field or silently store junk.
if binding.PipelineLocal {
continue
}
if binding.Positional {
// Pure positional (no flag aliases) is handled by
// collectPositionalBindings. Dual-mode positional bindings
+28 -1
View File
@@ -26,7 +26,8 @@ import (
)
// ApplyTransform applies a named transform rule to a value.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse, enum_map.
// Supported transforms: iso8601_to_millis, csv_to_array, json_parse,
// json_parse_strict, enum_map.
func ApplyTransform(value any, transform string, args map[string]any) (any, error) {
switch strings.TrimSpace(transform) {
case "":
@@ -37,6 +38,8 @@ func ApplyTransform(value any, transform string, args map[string]any) (any, erro
return transformCSVToArray(value)
case "json_parse":
return transformJSONParse(value)
case "json_parse_strict":
return transformJSONParseStrict(value)
case "enum_map":
return transformEnumMap(value, args)
default:
@@ -151,6 +154,30 @@ func transformJSONParse(value any) (any, error) {
)
}
// transformJSONParseStrict is the strict variant of json_parse: only accepts
// well-formed JSON, rejecting input that the YAML fallback would otherwise
// silently coerce to a scalar string. Use when the upstream tool requires a
// structured array/object value and "garbage in → empty out" is unacceptable.
func transformJSONParseStrict(value any) (any, error) {
s, ok := toString(value)
if !ok {
return value, nil
}
s = strings.TrimSpace(s)
if s == "" {
return value, nil
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return nil, apperrors.NewValidation(
"json_parse_strict: input is not valid JSON; " +
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
"as strict JSON (e.g. '[{\"key\":\"value\"}]') or use `json_parse` for YAML-tolerant parsing",
)
}
return parsed, nil
}
func transformEnumMap(value any, args map[string]any) (any, error) {
s, ok := toString(value)
if !ok {
+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)
}
}
// ---------------------------------------------------------------------------
+17 -4
View File
@@ -268,15 +268,20 @@ func newAttendanceShiftListCommand(runner executor.Runner) *cobra.Command {
func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "summary",
Short: "查询某个人的考勤统计摘要",
Long: "查询某个人的考勤统计摘要。--user 与 --date 均必填。",
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"`,
Use: "summary",
Short: "查询某个人的考勤统计摘要",
Long: `查询某个人的考勤统计摘要。
--user、--date、--stats-type 均必填。
钉钉服务端业务层强制要求 --stats-type(week/month),不填会返回 C0002 统计类型错误。`,
Example: ` dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
userID, _ := cmd.Flags().GetString("user")
workDateStr, _ := cmd.Flags().GetString("date")
statsType, _ := cmd.Flags().GetString("stats-type")
if userID == "" {
return apperrors.NewValidation("--user is required, provide DingTalk user ID")
}
@@ -287,10 +292,17 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
if err != nil {
return apperrors.NewValidation("--date format error, use yyyy-MM-dd HH:mm:ss")
}
if statsType == "" {
return apperrors.NewValidation(`--stats-type is required (week|month), enforced by DingTalk server`)
}
if statsType != "week" && statsType != "month" {
return apperrors.NewValidation(`--stats-type must be "week" or "month"`)
}
// Build nested structure QueryUserAttendVO
vo := map[string]any{
"userId": userID,
"queryDate": workDateStr,
"statsType": statsType,
}
params := map[string]any{
"QueryUserAttendVO": vo,
@@ -311,6 +323,7 @@ func newAttendanceSummaryCommand(runner executor.Runner) *cobra.Command {
}
cmd.Flags().String("user", "", "钉钉用户 ID(必填)")
cmd.Flags().String("date", "", "工作日期,格式 yyyy-MM-dd HH:mm:ss,如 2026-03-12 15:00:00(必填)")
cmd.Flags().String("stats-type", "", "统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求)")
preferLegacyLeaf(cmd)
return cmd
}
+13 -6
View File
@@ -92,12 +92,13 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
--open-dingtalk-id 指定 openDingTalkId 发单聊 (适用于无法获取 userId 的场景)。
三者只能选其一,不能同时指定。
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。必须提供 --title 作为消息标题。
消息内容通过 --text 传入,也可作为位置参数;支持 Markdown。
单聊消息(--user / --open-dingtalk-id)必须提供 --title 作为消息标题;群聊可选。
群聊场景下可用 --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 "请查收"
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 +128,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 +196,12 @@ 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")
}
// Direct-message tools (send_direct_message_as_user) reject an empty title at
// the API level with a misleading "发群服务窗会话消息失败" error, so fail loudly
// here instead. Group messages do not require a title.
if (hasUser || hasOpenID) && strings.TrimSpace(title) == "" {
return nil, "", apperrors.NewValidation("--title is required for direct messages (--user / --open-dingtalk-id)")
}
params := map[string]any{"text": text}
if strings.TrimSpace(title) != "" {
@@ -252,9 +259,9 @@ func newChatMessageSendByBotCommand(runner executor.Runner) *cobra.Command {
preferLegacyLeaf(cmd)
cmd.Flags().String("group", "", "群会话 openConversationId (群聊必填)")
cmd.Flags().String("robot-code", "", "机器人 Code")
cmd.Flags().String("text", "", "消息内容 (Markdown)")
cmd.Flags().String("title", "", "消息标题")
cmd.Flags().String("robot-code", "", "机器人 Code (必填)")
cmd.Flags().String("text", "", "消息内容 Markdown (必填)")
cmd.Flags().String("title", "", "消息标题 (必填)")
cmd.Flags().String("users", "", "接收者 userId 列表,逗号分隔,最多 20 个 (单聊必填)")
return cmd
}
+12 -2
View File
@@ -67,14 +67,14 @@ func TestChatMessageSendRoutesByDestination(t *testing.T) {
},
{
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",
@@ -132,6 +132,16 @@ func TestChatMessageSendRejectsInvalidDestination(t *testing.T) {
args: []string{"--group", "cid-x"},
wantErr: "--text (or positional argument) is required",
},
{
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) {
+13 -3
View File
@@ -204,8 +204,18 @@ func newReportCreateCommand(runner executor.Runner) *cobra.Command {
Use: "create",
Short: "创建日志",
Long: `按模版创建一条日志。--contents 为 JSON 数组,每项需含 key、sort、content、contentType、type,
与远程 create_report 一致;可先通过 report template list / template detail 取得 templateId 与控件定义。`,
Example: ` dws report create --template-id TPL_ID --contents '[{"content":"完成开发","sort":"0","key":"今日完成","contentType":"markdown","type":"1"}]'
与远程 create_report 一致;可先通过 report template list / template detail 取得 templateId 与控件定义。
注意:每个 contents 项的 key 必须精确等于模板的 field_name(中文/英文逐字匹配,不是控件 ID 或别名)。
key 与 field_name 不一致时钉钉 API 会返回 SYSTEM_ERROR (success=false),CLI 层不会预先拦截。
请先用 report template detail 查到 report_template_fields[].field_name 后再填。`,
Example: ` # Step 1:查模板,取 report_template_fields[].field_name 当作 contents[].key
dws report template detail --name "周报"
# Step 2:用上一步拿到的 field_name 作为 key 创建日志
dws report create --template-id TPL_ID --contents '[{"key":"<field_name>","sort":"0","content":"完成开发","contentType":"markdown","type":"1"}]'
# 同时通知到接收人单聊
dws report create --template-id TPL_ID --contents '[...]' --to-chat --to-user-ids userId1,userId2`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
@@ -251,7 +261,7 @@ func newReportCreateCommand(runner executor.Runner) *cobra.Command {
},
}
cmd.Flags().String("template-id", "", "日志模版 ID (必填)")
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填),每项含 key/sort/content/contentType/type")
cmd.Flags().String("contents", "", "日志内容 JSON 数组 (必填),每项含 key/sort/content/contentType/type;key 必须精确等于模板 field_name (用 report template detail --name <模板名> 查询)")
cmd.Flags().String("dd-from", "dws", "创建来源标识")
cmd.Flags().Bool("to-chat", false, "是否发送到日志接收人单聊")
cmd.Flags().String("to-user-ids", "", "接收人 userId,逗号分隔 (可选)")
+33 -4
View File
@@ -83,7 +83,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
Example: ` dws todo task create --title "修复线上Bug" --executors userId1,userId2 --priority 40
dws todo task create --title "提交报告" --executors userId1 --due "2026-03-10T18:00:00+08:00"
# 查询 userId: dws contact user search --keyword "姓名"`,
# 查询 userId: dws contact user search --query "姓名"`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -135,7 +135,7 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
preferLegacyLeaf(cmd)
cmd.Flags().String("title", "", i18n.T("待办标题 (必填)"))
cmd.Flags().String("executors", "", i18n.T("执行者 userId 列表 (必填)"))
cmd.Flags().String("executors", "", i18n.T("执行者 userId 列表,逗号分隔 (必填)。注意: 此处是通讯录 userId,可通过 dws contact user search --query 姓名 查询"))
cmd.Flags().String("due", "", i18n.T("截止时间 ISO-8601 (如 2026-03-10T18:00:00+08:00)"))
cmd.Flags().String("priority", "", i18n.T("优先级: 10低/20普通/30较高/40紧急"))
cmd.Flags().String("recurrence", "", i18n.T("循环待办 (需先设置 --due); 格式: DTSTART:...\\nRRULE:FREQ=DAILY;INTERVAL=1"))
@@ -152,8 +152,20 @@ func newTodoTaskCreateCommand(runner executor.Runner) *cobra.Command {
func newTodoTaskListCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: i18n.T("查询待办列表"),
Use: "list",
Short: i18n.T("查询待办列表"),
Long: i18n.T(`查询当前用户在当前企业的待办列表。
覆盖范围:
返回当前用户作为"执行者"(executor) 的待办。
仅参与但不执行的待办、自己创建但交给他人执行的待办不在返回范围内。
当前列表能力面向"个人待办",即钉钉待办模块中展示的待办任务,
不包含 OA 审批流待办、Teambition 项目任务等其他业务线的待办。
分页:
默认每页 20 条。--size 超过 20 时,CLI 会自动进行多次 API 调用
并合并结果(自动分页),无需手动翻页。`),
Example: ` dws todo task list --page 1 --size 20 --status false`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
@@ -362,6 +374,23 @@ func newTodoTaskGetCommand(runner executor.Runner) *cobra.Command {
cmd := &cobra.Command{
Use: "get",
Short: i18n.T("待办详情"),
Long: i18n.T(`查看待办任务详情。
返回字段说明:
creatorId / executorIds / participantIds / modifierId
待办系统内部人员标识(短数字 ID,如 6380165826),
不是通讯录 userId(如 035551044606950179)或 unionid。
这些 ID 在待办系统内对同一用户稳定,但无法直接用于通讯录 API 查询。
如需获取人员姓名,可参考返回中的 creatorInfo / executorInfos /
participantInfos 字段(包含 name 属性)。
bizTag / source
底层待办引擎的实现标识。即使是在钉钉客户端直接创建的普通个人待办,
也会返回 "teambition",这是内核实现细节,不代表来自 Teambition 产品。
tenantId / tenantType
待办所属的租户标识,非企业 corpId。tenantType 为 "user" 时
tenantId 是用户维度标识;为 "org" 时是组织维度标识。`),
Example: ` dws todo task get --task-id <taskId>
# 查询 taskId: dws todo task list`,
+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 {
+66 -2
View File
@@ -173,8 +173,16 @@ type CLIOutputFormat struct {
// CLIToolOverride maps an MCP tool to a CLI command with flag aliases and transforms.
type CLIToolOverride struct {
CLIName string `json:"cliName"`
Description string `json:"description,omitempty"`
CLIName string `json:"cliName"`
// CLIAliases registers additional cobra command aliases for the same MCP
// tool, so the leaf command can be invoked under multiple names without
// duplicating the override. Mirrors cobra.Command.Aliases. Each alias is
// added to the cobra Aliases slice; conflicts with existing siblings are
// silently ignored by cobra. Use for command-name normalisation (e.g.
// `range read` accepts `range get` as an alias) or hardcoded-command
// migration paths. Empty / nil means no extra aliases.
CLIAliases []string `json:"cliAliases,omitempty"`
Description string `json:"description,omitempty"`
// Example, when non-empty, is wired to cobra.Command.Example to render
// the "Examples:" section in --help. Mirrors hardcoded helper commands'
// Example field (e.g. wukong/products/oa.go list-forms). Empty value
@@ -212,6 +220,56 @@ type CLIToolOverride struct {
// fields (Flags / BodyWrapper / IsSensitive / ServerOverride) are
// ignored. Use for deprecated leaf commands that moved to a new path.
RedirectTo string `json:"redirectTo,omitempty"`
// Pipeline declares a multi-step orchestration: each step calls one
// MCP tool, with subsequent steps able to reference prior step outputs
// in their argument templates. PollUntilField/Value turn a step into a
// polling loop (for async jobs); type:"download" turns a step into an
// HTTP download sink that writes to a CLI-local --output flag. When
// Pipeline is non-empty, dispatch ignores the parent toolOverrides map
// key (no single "primary tool"); the executor walks the steps in
// order. CLI surface (CLIName / Group / Flags) still comes from the
// parent override; flags can be marked PipelineLocal=true to be
// consumed by the executor without being forwarded to MCP tools.
//
// See internal/compat/pipeline.go for the executor + envelope examples.
Pipeline []PipelineStep `json:"pipeline,omitempty"`
}
// PipelineStep declares one step in a multi-step CLIToolOverride.Pipeline.
// Templates supported in Args / DownloadURLField:
//
// $flag.<aliasName> — value of the user's CLI flag whose alias is
// <aliasName> (resolved at dispatch time).
// $step.<idx>.<dotPath> — field from a prior step's response, e.g.
// "$step.0.jobId" or "$step.1.result.url".
// literal value — passed through unchanged.
type PipelineStep struct {
// Type controls dispatch. Empty / "call" invokes Tool as an MCP tool.
// "download" treats this step as an HTTP GET sink (no MCP tool is
// invoked); URL is resolved from DownloadURLField.
Type string `json:"type,omitempty"`
// Tool is the MCP tool name to invoke for type=="call".
Tool string `json:"tool,omitempty"`
// Args maps MCP tool parameter names to template strings.
Args map[string]string `json:"args,omitempty"`
// PollUntilField, when non-empty (with PollUntilValue), turns this
// step into a polling loop: invoke repeatedly with the same Args
// until response[<PollUntilField>] equals PollUntilValue (string
// compare). Use for async-job patterns where a status field
// transitions to a terminal value (e.g. "done" / "succeeded").
PollUntilField string `json:"pollUntilField,omitempty"`
PollUntilValue string `json:"pollUntilValue,omitempty"`
PollIntervalSec int `json:"pollIntervalSec,omitempty"` // default 2 when polling
PollTimeoutSec int `json:"pollTimeoutSec,omitempty"` // default 300 when polling
// DownloadURLField (type=="download") is a $step.X.field template
// that resolves to an HTTP URL. The body is fetched via GET and
// written to the path given by OutputFlag's value. If OutputFlag's
// value is empty, the URL is printed to stdout for the user.
DownloadURLField string `json:"downloadURLField,omitempty"`
// OutputFlag (type=="download") names the CLI flag (alias) whose
// user-supplied value is the local destination path. When the path
// is a directory, the filename is inferred from the URL's basename.
OutputFlag string `json:"outputFlag,omitempty"`
}
// CLIFlagOverride describes how to map an MCP parameter to a CLI flag.
@@ -261,6 +319,12 @@ type CLIFlagOverride struct {
// Resolution comes from edition.Hooks.RuntimeDefaults; open-source core
// only recognises the placeholder set. See schema v3 §2.3.
RuntimeDefault string `json:"runtimeDefault,omitempty"`
// PipelineLocal, when true, marks this flag as CLI-side only — its
// value is consumed by the pipeline executor (e.g. as an HTTP
// download destination) and NOT forwarded to any MCP tool's params.
// Only meaningful when the enclosing CLIToolOverride.Pipeline is set.
// Use for flags like `--output` that describe local destination paths.
PipelineLocal bool `json:"pipelineLocal,omitempty"`
}
type CLITool struct {
+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)
}
})
}
}
+5
View File
@@ -211,6 +211,11 @@ func (r *ToolCallResult) UnmarshalJSON(data []byte) error {
// "accepted but never responded" servers faster, and explicit TLS/dial timeouts.
func defaultTransport() *http.Transport {
return &http.Transport{
// Honour HTTP_PROXY / HTTPS_PROXY / NO_PROXY env vars; a custom
// Transport without an explicit Proxy field would otherwise bypass
// proxies entirely, which breaks sandboxed/air-gapped deployments
// that rely on an outbound proxy (#236).
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 3 * time.Second,
KeepAlive: 30 * time.Second,
+45
View File
@@ -0,0 +1,45 @@
// 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 transport
import (
"net/http"
"reflect"
"testing"
)
// TestDefaultTransportHonoursHTTPProxyEnv guards the fix for issue #236:
// the MCP HTTP transport must honour HTTP_PROXY / HTTPS_PROXY env vars.
// A custom Transport built without an explicit Proxy field defaults to
// "no proxy" — sandboxed deployments behind an outbound proxy would then
// silently bypass the proxy and fail.
//
// We assert tr.Proxy points at http.ProxyFromEnvironment rather than invoking
// it, because http.ProxyFromEnvironment memoises env on first call (Go's
// envProxyOnce). Test ordering with anything else that reads proxy env early
// would make a value-based assertion flaky. The runtime reads env at process
// boot — pointing at the stdlib resolver is the contract we need.
func TestDefaultTransportHonoursHTTPProxyEnv(t *testing.T) {
t.Parallel()
tr := defaultTransport()
if tr.Proxy == nil {
t.Fatal("defaultTransport().Proxy is nil — HTTP_PROXY/HTTPS_PROXY env will be ignored (regression of #236)")
}
wantPC := reflect.ValueOf(http.ProxyFromEnvironment).Pointer()
gotPC := reflect.ValueOf(tr.Proxy).Pointer()
if gotPC != wantPC {
t.Errorf("defaultTransport().Proxy is not http.ProxyFromEnvironment — env-var proxy may not be honoured (regression of #236)")
}
}
+10 -1
View File
@@ -19,7 +19,15 @@ const (
)
// knownSkillDirs lists all known Agent skill directories (relative to $HOME).
// Kept in sync with build/npm/install.js AGENT_DIRS.
// Kept in sync with:
// - build/npm/install.js AGENT_DIRS
// - scripts/install.sh for-in list
// - scripts/install.ps1 $AgentDirs
// - scripts/install-skills.sh for-in list
// - build/homebrew.rb.tmpl targets
// - test/scripts/package_script_test.go expectedPackagedSkillTargets
// - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS
//
// The first entry (.agents/skills) is always updated; subsequent entries are
// only updated when their parent directory already exists.
var knownSkillDirs = []string{
@@ -36,6 +44,7 @@ var knownSkillDirs = []string{
".kiro/skills",
".trae/skills",
".openclaw/skills",
".hermes/skills",
}
// skillDirBlacklist contains parent directories whose skills are managed by
+2 -1
View File
@@ -116,7 +116,8 @@ install_skills_to_root() {
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills"
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
+2 -1
View File
@@ -44,7 +44,8 @@ $AgentDirs = @(
".amp\skills",
".kiro\skills",
".trae\skills",
".openclaw\skills"
".openclaw\skills",
".hermes\skills"
)
# ── Helpers ──────────────────────────────────────────────────────────────────
+2 -1
View File
@@ -201,7 +201,8 @@ install_skills_to_homes() {
".amp/skills" \
".kiro/skills" \
".trae/skills" \
".openclaw/skills"
".openclaw/skills" \
".hermes/skills"
do
base_dir="$root/$agent_dir"
parent_gate="$(dirname "$base_dir")"
@@ -37,6 +37,7 @@ HOME_AGENT_PARENTS="
.kiro
.trae
.openclaw
.hermes
"
HOME_SKILL_TARGETS="
.agents/skills/dws
@@ -52,6 +53,7 @@ HOME_SKILL_TARGETS="
.kiro/skills/dws
.trae/skills/dws
.openclaw/skills/dws
.hermes/skills/dws
"
cleanup() {
if command -v brew >/dev/null 2>&1; then
+5 -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(待办任务)
+2 -2
View File
@@ -91,7 +91,7 @@
```bash
# 1. 搜索同事 userId
dws contact user search --keyword "张三" --format json
dws contact user search --query "张三" --format json
# 2. 创建日程
dws calendar event create --title "会议" \
@@ -107,7 +107,7 @@ dws calendar participant add --event <EVENT_ID> --users <USER_ID> --format json
```bash
# 1. 搜索同事 userId
dws contact user search --keyword "张三" --format json
dws contact user search --query "张三" --format json
# 2. 创建待办
dws todo task create --title "任务内容" --executors <USER_ID> --format json
+7 -3
View File
@@ -32,12 +32,16 @@ Flags:
Usage:
dws attendance summary [flags]
Example:
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00"
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type month
dws attendance summary --user USER_ID --date "2026-03-12 15:00:00" --stats-type week
Flags:
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
--user string 钉钉用户 ID (必填)
--date string 工作日期, 格式 yyyy-MM-dd HH:mm:ss (必填)
--stats-type string 统计类型:week(周统计)或 month(月统计)(必填,钉钉服务端业务层强制要求;CLI 层会直接拒绝缺失/非法值)
--user string 钉钉用户 ID (必填)
```
> ⚠️ **重要**:`--stats-type` 在钉钉 schema 中标记为 `required: []`(看似可选),但服务端业务层**强制要求**,不传服务端会回 `C0002 / 统计类型错误`。CLI 已在客户端层做了 fail-fast:缺失或非 `week`/`month` 的取值会直接被 CLI 拒绝,不会发出请求。
### 查询考勤组与考勤规则
```
Usage:
+38 -6
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。单聊消息(--user / --open-dingtalk-id)必须提供 --title 作为消息标题;群聊可选。
--群聊时可选 --at-all @所有人,或 --at-users 指定成员(仅群聊时生效)。
--发送图片消息:指定 --media-id(通过 dt_media_upload 工具上传获得),自动设置 msgType=image,此时不需要传文本内容。
@@ -192,8 +192,8 @@ 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 --user <userId> --title "提醒" --text "请查收"
dws chat message send --open-dingtalk-id <openDingTalkId> --title "提醒" --text "请查收"
dws chat message send --group <openconversation_id> "hello"
dws chat message send --group <openconversation_id> --title "周报提醒" --text "请大家本周五前提交周报"
dws chat message send --group <openconversation_id> --at-all "<@all> 请大家注意"
@@ -232,31 +232,63 @@ Flags:
拉取指定群聊或单聊的会话消息内容。
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。hasMore=true 时用结果中的边界 createTime 作为下次 --time 翻页。
--group 指定群聊,--user 指定单聊用户(通过 userId),--open-dingtalk-id 指定单聊用户(通过 openDingTalkId),三者互斥。默认拉取给定时间之后的消息,--forward=false 拉之前的。
```
Usage:
dws chat message list [flags]
Example:
# 拉取群聊中某个时间点之后的消息
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00"
# 拉取单聊消息(通过 userId)
dws chat message list --user <userId> --time "2025-03-01 00:00:00" --limit 50
# 拉取单聊消息(通过 openDingTalkId)
dws chat message list --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --limit 50
# 拉取某个时间点之前的消息(向过去翻页)
dws chat message list --group <openconversation_id> --time "2025-03-01 00:00:00" --forward=false
Flags:
--forward true=拉给定时间之后的消息,false=拉给定时间之前的消息 (default true)
--group string 群聊 openconversation_id(群聊时必填)
--limit int 返回数量,不传则不限制
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss (必填)
--time string 开始时间,格式: yyyy-MM-dd HH:mm:ss(不传则默认拉取最新消息)
--user string 单聊用户 userId(单聊时与 --open-dingtalk-id 二选一)
--open-dingtalk-id string 单聊用户 openDingTalkId(单聊时与 --user 二选一,适用于三方应用等无法获取 userId 的场景)
注意:
- --group、--user、--open-dingtalk-id 三者互斥,只需指定其一:群聊用 --group,单聊用 --user 或 --open-dingtalk-id
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- 翻页:hasMore=true 时,用结果中的边界 createTime 作为下次 --time
- 如果返回的会话消息中包含 openConvThreadId 字段,说明是话题类消息,需要调用 dws chat message list-topic-replies 拉取话题的回复内容列表,openConvThreadId 作为 --topic-id 参数
```
### 分页翻页说明(重要)
`message list` 的翻页方式与 `message list-all` **完全不同**,请勿混淆:
| 命令 | 翻页参数 | 翻页值来源 | 值格式 |
|------|---------|-----------|--------|
| `message list` | `--time` | 上一页结果中**最后一条消息的 `createTime` 字段** | `yyyy-MM-dd HH:mm:ss`(如 `"2025-03-01 14:30:00"`) |
| `message list-all` | `--cursor` | 上一页响应中的 `nextCursor` 字段 | 字符串(如 `"abc123token"`) |
**翻页步骤(message list):**
1. **首次请求**:指定起始时间
```bash
dws chat message list --group <id> --time "2025-03-01 00:00:00" --limit 50 --format json
```
2. **检查响应**:查看 `hasMore` 字段
- `hasMore: false` → 没有更多消息,翻页结束
- `hasMore: true` → 还有更多消息,继续下一步
3. **获取翻页时间**:取返回结果中**最后一条消息**的 `createTime` 字段值(如 `"2025-03-01 14:30:00"`)
4. **下一页请求**:将该 `createTime` 作为 `--time` 传入
```bash
dws chat message list --group <id> --time "2025-03-01 14:30:00" --limit 50 --format json
```
5. 重复步骤 2-4 直到 `hasMore: false`
> ⚠️ **常见错误**:
> - **不要把 `nextCursor` 传给 `--time`**:响应中的 `nextCursor` 字段(纯数字时间戳如 `1776684611219`)**不是给 `--time` 用的**。`--time` 只接受 `yyyy-MM-dd HH:mm:ss` 格式。将 `nextCursor` 传给 `--time` 会导致返回相同页面,陷入死循环。`nextCursor` 仅用于 `message list-all` 的 `--cursor` 参数。
> - **不要把 `nextCursor` 传给 `--forward`**:`--forward` 只接受 `true`(拉给定时间之后的消息)或 `false`(拉给定时间之前的消息),不是时间戳或游标参数。
---
## message list-all — 拉取指定时间范围内当前用户的所有会话消息
+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) — 钉盘文件存储/上传/下载
+112 -6
View File
@@ -1,6 +1,8 @@
package cli_compat_test
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
@@ -146,7 +148,11 @@ func TestAttendanceShiftList_should_filter_empty_user_ids(t *testing.T) {
func TestAttendanceSummary_should_call_tool_with_user_and_date(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -157,8 +163,9 @@ func TestAttendanceSummary_should_pass_user_and_date_flags(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
last := cap.last()
if last == nil {
@@ -200,10 +207,14 @@ func TestAttendanceSummary_should_error_when_user_missing(t *testing.T) {
}
}
func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
func TestAttendanceSummary_should_pass_user_id_through_vo(t *testing.T) {
cap := setupTestDeps(t, "attendance")
root := buildRoot()
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U002", "date": "2026-03-12 15:00:00"})
_ = execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U002",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
last := cap.last()
if last == nil {
t.Fatal("no call captured")
@@ -220,7 +231,11 @@ func TestAttendanceSummary_should_pass_only_user_flag(t *testing.T) {
func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
cap := setupTestDepsWithDryRun(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{"user": "U001", "date": "2026-03-12 15:00:00"})
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -229,6 +244,97 @@ func TestAttendanceSummary_should_use_dry_run_mode(t *testing.T) {
}
}
// execSummaryDryRun runs attendance summary with --dry-run and returns the
// parsed QueryUserAttendVO map from the helper_invocation payload. This is
// necessary because the attendance handler is a custom helper (not a dynamic
// MCP-route), so the test framework's mcpCallCapture cannot intercept the
// call in non-dry-run mode.
func execSummaryDryRun(t *testing.T, flags map[string]string) map[string]any {
t.Helper()
root := buildRoot()
_ = setupTestDeps(t, "attendance")
cliArgs := []string{"-f", "json", "attendance", "summary", "--dry-run"}
for k, v := range flags {
if v != "" {
cliArgs = append(cliArgs, "--"+k, v)
}
}
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&bytes.Buffer{})
root.SetArgs(cliArgs)
if err := root.Execute(); err != nil {
t.Fatalf("unexpected CLI error: %v", err)
}
var payload struct {
Params map[string]any `json:"params"`
}
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("failed to parse dry-run output as JSON: %v\noutput: %s", err, out.String())
}
vo, ok := payload.Params["QueryUserAttendVO"].(map[string]any)
if !ok {
t.Fatalf("expected QueryUserAttendVO map in dry-run params, got %T: %v", payload.Params["QueryUserAttendVO"], payload.Params)
}
return vo
}
func TestAttendanceSummary_should_pass_stats_type_when_provided(t *testing.T) {
vo := execSummaryDryRun(t, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "month",
})
if vo["statsType"] != "month" {
t.Errorf("expected statsType=month in VO, got %v", vo["statsType"])
}
}
func TestAttendanceSummary_should_pass_stats_type_week(t *testing.T) {
vo := execSummaryDryRun(t, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "week",
})
if vo["statsType"] != "week" {
t.Errorf("expected statsType=week in VO, got %v", vo["statsType"])
}
}
func TestAttendanceSummary_should_error_when_stats_type_missing(t *testing.T) {
_ = setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
})
if err == nil {
t.Fatal("expected error when --stats-type is missing")
}
if !strings.Contains(err.Error(), "stats-type") {
t.Errorf("expected error message to mention stats-type, got: %v", err)
}
}
func TestAttendanceSummary_should_error_when_stats_type_invalid(t *testing.T) {
_ = setupTestDeps(t, "attendance")
root := buildRoot()
err := execCmd(t, root, []string{"attendance", "summary"}, map[string]string{
"user": "U001",
"date": "2026-03-12 15:00:00",
"stats-type": "foobar",
})
if err == nil {
t.Fatal("expected error when --stats-type is neither week nor month")
}
if !strings.Contains(err.Error(), "week") || !strings.Contains(err.Error(), "month") {
t.Errorf("expected error message to mention week and month, got: %v", err)
}
}
// ── attendance rules ───────────────────────────────────────
func TestAttendanceRules_should_call_tool_with_date(t *testing.T) {
+1
View File
@@ -23,6 +23,7 @@ var expectedPackagedSkillTargets = []string{
".kiro/skills/dws",
".trae/skills/dws",
".openclaw/skills/dws",
".hermes/skills/dws",
}
// seedDistArtifacts creates fake goreleaser output archives (empty tar.gz/zip
+8 -8
View File
@@ -776,13 +776,13 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**contact_contact_dept_search_001**
- Prompt: 搜索部门,keyword 为 技术部
- Expected: `dws contact dept search --keyword 技术部 --format json`
- Flags: `--keyword` = `技术部`
- Expected: `dws contact dept search --query 技术部 --format json`
- Flags: `--query` = `技术部`
**contact_contact_dept_search_002**
- Prompt: 搜一下技术部的部门信息
- Expected: `dws contact dept search --keyword "技术部" --format json`
- Flags: `--keyword` = `技术部`
- Expected: `dws contact dept search --query "技术部" --format json`
- Flags: `--query` = `技术部`
#### `dws contact user get`
@@ -810,13 +810,13 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
**contact_contact_user_search_001**
- Prompt: 按关键词搜索用户,keyword 为 张三
- Expected: `dws contact user search --keyword 张三 --format json`
- Flags: `--keyword` = `张三`
- Expected: `dws contact user search --query 张三 --format json`
- Flags: `--query` = `张三`
**contact_contact_user_search_002**
- Prompt: 帮我找一下张三的联系方式
- Expected: `dws contact user search --keyword "张三" --format json`
- Flags: `--keyword` = `张三`
- Expected: `dws contact user search --query "张三" --format json`
- Flags: `--query` = `张三`
#### `dws contact user search-mobile`
+4 -4
View File
@@ -1014,7 +1014,7 @@
**contact_contact_dept_search_001** ✅ PASS
- Prompt: 搜索部门,keyword 为 技术部
- Expected: `dws contact dept search --keyword 技术部 --format json`
- Expected: `dws contact dept search --query 技术部 --format json`
- Skill Reference: references/products/contact.md
- Command path: PASS (contact dept search)
- Flags: PASS (1 flags validated)
@@ -1022,7 +1022,7 @@
**contact_contact_dept_search_002** ✅ PASS
- Prompt: 搜一下技术部的部门信息
- Expected: `dws contact dept search --keyword "技术部" --format json`
- Expected: `dws contact dept search --query "技术部" --format json`
- Skill Reference: references/products/contact.md
- Command path: PASS (contact dept search)
- Flags: PASS (1 flags validated)
@@ -1062,7 +1062,7 @@
**contact_contact_user_search_001** ✅ PASS
- Prompt: 按关键词搜索用户,keyword 为 张三
- Expected: `dws contact user search --keyword 张三 --format json`
- Expected: `dws contact user search --query 张三 --format json`
- Skill Reference: references/products/contact.md
- Command path: PASS (contact user search)
- Flags: PASS (1 flags validated)
@@ -1070,7 +1070,7 @@
**contact_contact_user_search_002** ✅ PASS
- Prompt: 帮我找一下张三的联系方式
- Expected: `dws contact user search --keyword "张三" --format json`
- Expected: `dws contact user search --query "张三" --format json`
- Skill Reference: references/products/contact.md
- Command path: PASS (contact user search)
- Flags: PASS (1 flags validated)