send_personal_message packs the message body via json.Marshal, whose
default HTML escaping rewrites <@openDingTalkId> / <@all> into
<@...>. The DingTalk client renders an @-mention by matching the
literal <@...> token, so the escaped form is shown as plain text and the
@ never renders (API still returns success, masking the bug).
Marshal the content with SetEscapeHTML(false) for both the group and the
direct send_personal_message paths. Add a regression test asserting the
content keeps literal <@...> tokens and is never HTML-escaped.
Verified live: @someone and @all now render as blue mentions in the client.
- printExecutionError: in JSON mode emit the structured error to stdout
(not stderr) so machine consumers parsing stdout get a parseable result;
update the 3 root_execute tests to assert the new contract.
- requireYesForDelete prompt: when stdin is not a TTY (piped/scripted/JSON
pipelines), do not show an interactive confirm that emits non-JSON and
blocks on stdin; return a structured validation error requiring --yes.
- printExecutionError: in JSON mode emit the structured error to stdout
(not stderr) so machine consumers parsing stdout get a parseable result;
update the 3 root_execute tests to assert the new contract.
- requireYesForDelete prompt: when stdin is not a TTY (piped/scripted/JSON
pipelines), do not show an interactive confirm that emits non-JSON and
blocks on stdin; return a structured validation error requiring --yes.
Remove the entire code surface of the aiapp (AI application: create/query/modify) product:
- Delete internal/helpers/aiapp.go and its test (the init() static command registration)
- Drop aiapp from generator coverage targets and knownRegistryProducts
- Remove aiapp from the operations-analyst persona services in personas.yaml
- Delete skills (mono references/products/aiapp.md + dingtalk-aiapp multi skill + script)
- Update README/README_zh product tables and counts (19->18 products, 20->19 multi skills) and run_skill_tests mapping
- Add a Removed entry to CHANGELOG
The service-discovery side (envelope/discovery.pre.json, Portal-synced and gitignored) removes the aiapp server block separately.
Extract the server-list endpoint path into a single discoveryAPIPath constant and move it from /cli/discovery/apis to /cli/discovery/apis/bamboo so future version bumps touch one place.
Only the path changes: the MCP base host stays on production https://mcp.dingtalk.com and the auth / skill / doctor endpoints are untouched. The edition DiscoveryURL hook (full-URL FetchServersFromURL) is unaffected. All mock/test fixtures are updated to the new path and a CHANGELOG 1.0.34 entry is added.
The conference-local stdio plugin runs an Initialize/ListTools handshake
during command-tree construction. When the DingTalk desktop client isn't
running it returns "本地服务未就绪", which was logged at Warn and printed
to stderr on every invocation.
Because discovery happens in NewRootCommandWithEngine — before
PersistentPreRunE applies --debug/--verbose via configureLogLevel — this
Warn showed regardless of flags, polluting output and misleading callers
(and LLMs) into treating it as the cause of an unrelated command error
(e.g. an auth error or PARAM_ERROR from a different server).
This is an expected, benign outcome for an optional local plugin:
commands that ship toolOverrides still register up-front via
registerStdioServerFromOverlay, so availability is unaffected. Downgrade
both discovery handshake failures to Debug so normal output stays clean.
* fix(drive): drop client-side Content-Type fallback on presigned OSS PUT
The drive helper used to set req.Header["Content-Type"] = fallbackMIME
whenever the prepare_upload response returned an empty headers map.
DingTalk drive uses OSS presigned URLs whose StringToSign is computed
against an empty Content-Type at signing time, so any client-side
Content-Type makes the signature OSS recomputes at PUT time differ
from the server's presignature → HTTP 403 SignatureDoesNotMatch.
This broke every `dws drive upload` for any file whose mime detects
to a non-empty value (image/png, application/pdf, etc.), which is
basically everything in practice.
Reproduction (against DingTalk drive):
dws drive upload --file any.png
→ [1/3] 获取上传凭证 any.png (X 字节, image/png)...
→ [2/3] 上传文件到 OSS...
→ OSS 上传失败 HTTP 403: SignatureDoesNotMatch
Fix: trust the server's headers map as authoritative. Empty map means
"no client-side headers needed" — do not infer and add Content-Type.
Removes the hasContentType / fallbackMIME path entirely; httpPutDriveFile
signature loses the fallbackMIME parameter.
Manual verification:
curl -X PUT -H "Content-Type:" --data-binary @file <same-presigned-url>
→ HTTP 200 (proves the only difference was the client-side Content-Type)
Note: aitable.go's upload-file helper deliberately keeps its
Set("Content-Type", mimeType) call because its OSS endpoint uses a
different signing mode (server includes the client-declared mime in
its signature computation, verified by running aitable upload-file
across 12 file types — all succeed). The two helpers must not be
unified without re-validating both endpoints.
Tests:
- TestHttpPutDriveFile_NoContentTypeWhenServerHeadersEmpty: guards
that an empty server headers map results in no Content-Type on PUT.
- TestHttpPutDriveFile_PassthroughServerHeaders: guards that server
headers (Content-Type, x-oss-*) are forwarded verbatim.
* feat(aitable): unhide attachment upload-file + clarify prepare-only command
AI agents that discover commands only via --help (e.g. Lobster, generic
LLM agents) currently hit a dead end when trying to upload an attachment
to an AITable:
- `dws aitable attachment` exposes only `upload` (prepare-only),
which returns uploadUrl + fileToken but does not actually upload.
- The real one-shot command `attachment upload-file` (which performs
prepare + HTTP PUT + return fileToken automatically) is marked
Hidden:true, so it is invisible in --help output.
Agents that don't read skills/references/products/aitable.md therefore
get stuck after step 1 — they call `upload`, receive an uploadUrl they
have no idea how to consume, attempt various wrong things (e.g. write
the uploadUrl into the record's attachment field as if it were a token),
fail, and fall back to "please use the UI to upload" messages, which
makes dws look broken even though the capability is fully implemented.
This is the same UX gap that lark-cli avoids with its highly visible
`base +record-upload-attachment` command — discoverability via --help
is the difference between "works for any agent" and "only works if the
agent reads our skill docs".
Changes:
- newAITableUploadFileCommand: remove Hidden:true so the command
appears in `dws aitable attachment --help`. Tighten Short to
explicitly mention the 3 steps it bundles. Long now also calls
out the prepare-only sibling and recommends this command as the
default for AI agents.
- newAITableAttachmentUploadCommand (prepare-only): add a Long
description that explicitly states this command is only step 1
of a 3-step flow, lists what an agent must do after (HTTP PUT,
then write fileToken into record attachment field with the exact
[{"fileToken":"ft_xxx"}] shape), and points to upload-file as
the recommended one-shot alternative. Updated Short to flag that
no file is uploaded by this command.
- aitable_upload_file_test: add TestAITableUploadFileCommandIsDiscoverable
to guard against re-introducing Hidden:true. The test includes a
rationale comment explaining the agent-discoverability gap.
The drive Content-Type fix in the preceding commit and this aitable
discoverability fix together restore end-to-end attachment upload
functionality for both human operators and AI agents that only read
--help.
* feat(root): show 'dws upgrade' guidance in dws --help when commands are missing
AI agents (and users) reading `dws --help` see only the discovered MCP
service list and utility command list, with no hint about what to do if
none of the listed commands fit their task. The natural failure mode is
to give up or hack around — but in many cases the right action is just
`dws upgrade`, because new capabilities and bugfixes ship continuously
and a missing command is usually a stale binary issue.
Two changes restore visibility of the root command's Long description so
this guidance can be surfaced:
- internal/app/root.go: set root.Long to a one-paragraph hint —
"if you hit a missing command, an error, or cannot complete the
task, try `dws upgrade` first; both the DingTalk OpenAPI surface
and dws CLI evolve continuously."
- internal/app/root_help.go: the custom SetHelpFunc that renders the
root help (renderRootHelp) replaces cobra's default template, which
had been silently dropping root.Long from --help output. Restore
rendering by appending root.Long (when non-empty) after the command
list, separated by a blank line. This matches cobra's default
behavior for the Long field while preserving the custom services /
utilities sections renderRootHelp introduced.
- internal/app/visibility_test.go: new TestRenderRootHelpIncludesLong
guards against re-introducing the regression. Uses a sentinel Long
string and asserts renderRootHelp output contains it verbatim. If a
future change rewrites the help renderer without preserving Long
rendering, this test fails immediately and reminds the author the
upgrade hint must stay visible.
Verified locally:
$ ./dws --help | tail -3
Use "dws <service> --help" for more information about a discovered MCP
service or "dws <command> --help" for utility commands.
提示: 如果遇到能力缺失、命令报错、新功能未注册、或无法完成任务,
请先用 'dws upgrade' 升级到最新版本后再试. 钉钉 OpenAPI 和 dws CLI
持续迭代, 新能力和 bugfix 会先在新版本上线.
GoReleaser cross-compiles darwin/arm64 binaries on ubuntu-latest with no
codesign step. macOS 11+ on Apple Silicon requires at least an ad-hoc
signature; unsigned arm64 binaries are SIGKILL'd by amfid on first exec,
which surfaces as `signal: killed` and aborts `dws upgrade` at the
"解压并验证" step.
Two layers of fix:
1. Release-side: post-goreleaser.sh now unpacks each dws-darwin-*.tar.gz,
applies an ad-hoc signature (codesign locally, rcodesign in CI),
deterministically repacks, and rewrites the matching line in
checksums.txt. release.yml installs rcodesign 0.27.0 before
GoReleaser runs.
2. Client-side self-heal: validateNewBinary detects `signal: killed` on
darwin and retries once after running `codesign --force --sign -` and
clearing com.apple.quarantine. Future releases stay functional even
if the signing step is ever skipped.
Verified end-to-end: stripped a real dws binary → exec exits 137 →
validateNewBinary recovers → final binary shows Signature=adhoc and runs.
* fix(auth): partition app.json filename by edition to isolate credentials
Two dws binaries sharing the same config directory (typically ~/.dws or
DWS_CONFIG_DIR) previously read and wrote a single app.json. Editions
that pin AuthClientID via hooks still go through the open-core
post-login persistence path, which records a bare ClientID without a
paired ClientSecret. The sibling edition reading the same path would
then adopt that foreign clientID via ResolveAppCredentials.
Mirror the strategy already used by the cache loader
(pkg/config.EditionPartition): GetAppConfigPath returns a filename
suffixed with the active edition name. Open-source keeps "app.json" for
backwards compatibility; sibling editions land on "app-<edition>.json".
LoadAppConfig / SaveAppConfig / HasAppConfig / DeleteAppConfig all
route through GetAppConfigPath, so this single change physically
isolates credential files end-to-end without any read-time heuristics.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(auth): address app config partition review
* fix(auth): clean legacy sibling app config
* test(auth): cover legacy app config cleanup guards
* fix(auth): close app config review follow-ups
---------
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
NewDirectCommand was hard-coding cobra.NoArgs for envelope-generated
leaf commands that have no positional bindings (totalMax == 0). This
is stricter than cobra's own default — legacyArgs (args.go:30-32)
returns nil for any command without subcommands.
The strict behavior surfaced as "unknown command \"<word>\" for
\"dws aisearch person\"" whenever an AI agent passed trailing
positional words after a leaf, e.g.
dws aisearch person search --keyword "张"
dws aisearch person user search --keyword "张"
Switching to cobra.ArbitraryArgs restores cobra's natural leaf
behavior: trailing positional args are silently ignored. Existing
positional-binding paths (MinimumNArgs / RangeArgs / MaximumNArgs)
are unchanged.
Verified against dws-wukong/auto-test/cli_to_mcp/testcases on
aiapp / live / aisearch: 50/50 pass (was 48/50; the 2 remaining
failures were the F-class extra-positional-args tolerance cases
this commit fixes).
* fix(app): prevent command field from overwriting existing endpoint
In AppendDynamicServer, when a plugin declares command != id, the command
endpoint is written unconditionally, overwriting any previously registered entry.
Fix: use first-writer-wins - only write if the key is not yet present.
id registration and dynamicProducts remain unconditional (unaffected).
* test(app): add regression test for command endpoint first-writer-wins guard
`send_message_as_user`'s schema also marks `title` as required, but
`buildChatMessageSendInvocation` only pre-validated it for direct
messages. Sending `dws chat message send --group <cid> --text "1"`
without `--title` therefore reached the API and returned the same
misleading `发群服务窗会话消息失败` business error that #250 fixed for
direct messages, just on the other branch.
Pre-validation now covers both branches: group sends without title
return `--title is required for group messages (--group)`, direct sends
keep the existing `--title is required for direct messages (...)`
message. `Long` help, the `--title` flag description, the first
`Example` line, and `skills/references/products/chat.md` (including the
deeper "drive → chat" workflow example) are realigned to
"群聊与单聊都必填". `internal/helpers/chat_test.go` gains a
`group-without-title` case, and the existing `group` / `positional-text`
success cases are updated to pass `--title`. No API request shape change.
* fix(transport): suppress noisy WARN on stdio client shutdown
When Stop() kills the subprocess, cmd.Wait() always returns a non-zero
exit code which is expected behavior. Previously this propagated as an
error, causing "failed to stop stdio client ... exit status 1" warnings
on every normal CLI exit for stdio-based plugins.
Now Stop() returns nil when the process was explicitly killed, keeping
the error path only for cases where the process exits on its own with
a non-zero code (e.g. stdin close without kill).
🤖 Generated with [Qoder][https://qoder.com]
* fix(transport): address review feedback — simplify Stop(), fix test assertion
- Remove redundant `killed` flag; inline Kill+Wait+return nil
- Update integration test to positively assert Stop() returns nil after kill
🤖 Generated with [Qoder][https://qoder.com]
* feat(transform): add file_read transform for UTF-8 file / stdin content (#277)
Introduces a new ApplyTransform case "file_read":
- Input: a string flag value treated as a UTF-8 file path
- Special case: the path "-" reads from stdin
- Output: the file contents as a string
- Errors surface as validation errors (exit 2) — non-UTF-8, missing
file, empty path, and non-string input all reject cleanly
This is a foundational primitive intended to compose with envelope
schema features so a CLI flag like --content-file can carry a path
that ultimately feeds a string-typed MCP parameter with the file's
contents.
## Scope note — MapsTo intentionally NOT included
The original proposal in #277 paired this transform with a new
CLIFlagOverride.MapsTo field that retargets a flag's value to a
different MCP parameter (e.g. --content / --content-file both feed
the upstream `markdown` param). Pre-production end-to-end validation
(see #282) showed that the MCP registry server-side schema does not
currently recognise `mapsTo` and strips it on serialisation. Every
other new envelope field (hidden / required / transform /
mutuallyExclusive / requireOneOf) survives — only mapsTo is dropped.
Shipping MapsTo without server-side support would land dead client
code. The MapsTo field + sourceFlag transform guard were therefore
removed from this PR; they will return in a follow-up PR once #282
(server-side schema acknowledgement of mapsTo) is resolved. The
file_read transform stays here because it composes with multiple
mechanisms beyond MapsTo and is independently testable.
## Tests
Six new cases in internal/compat/transform_test.go cover the
contract: literal file, stdin via "-", missing file, non-UTF-8
input, empty path, non-string input. All pass under both `go test`
and `go test -coverprofile`.
Refs #277, blocked-by #282
* feat(schema): add CLIFlagOverride.MapsTo for sibling-flag routing (#277#282)
Adds the `MapsTo` field on `CLIFlagOverride` and wires the dispatch loop
in `compat.buildOverrideBindings` so a flag's final value (post-transform
or literal) is routed into a different MCP parameter slot than its own
property name. This lets two sibling CLI flags feed a single upstream
parameter — the canonical case being `--content` (literal) + `--content-file`
(transform: file_read) both mapping to `markdown`.
Tool-level `CLIToolOverride.MutuallyExclusive` (cobra MarkFlagsMutuallyExclusive)
is the right partner for guarding "set one, not both" at parse time; no
new exclusion machinery is added.
Closes the client-side gap previously misattributed to a server-side
mapsTo strip in #282. Once a doc envelope with mapsTo lands in pre-prod,
end-to-end `--content-file` becomes shippable, finishing #277 Step 1b.
Test coverage (internal/compat/dynamic_commands_test.go):
- MapsTo without transform: literal --content → params[markdown]
- MapsTo with file_read transform: --content-file path → params[markdown]
- Sibling flags both mapsTo same target, only one set: clean routing
- Sibling flags both set: rejected by tool-level MutuallyExclusive (regression)
Backward-compat: empty MapsTo preserves existing params[propertyName] write
semantics for every existing envelope.
* chore: update coverage badge [skip ci]
* chore: update coverage badge [skip ci]
* chore: update coverage badge [skip ci]
* chore: update coverage badge [skip ci]
* chore: update coverage badge [skip ci]
* 1.0.19 changelog
* stick opt
* fix(cli): utf-8 safe sticky suffix guard + changelog (#272)
SuffixLooksLikeValue used to read suffix[0] (a single byte) for both the
uuid format branch and the fallback "is the first rune a letter?" check.
For multi-byte UTF-8 leading runes — common in dws because value text is
often Chinese — this picked up only the first byte (0xE0..0xF4 lead),
which is not a letter and not a hex digit, so the function silently
returned true and let glued tokens like --name<CJK> get split into
--name <CJK>... This switches both branches to utf8.DecodeRuneInString
and adds a utf8.RuneError guard so invalid UTF-8 input is rejected too.
Also locks down the new behaviour in CHANGELOG ## [Unreleased]:
- Changed: schema-aware sticky flag splitting
- Added: available_flags field on unknown-flag errors
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
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
* 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.
* 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>
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).
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.
* 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
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).
* 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>
* 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>
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>
- 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#106Closes#107
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>