Compare commits

...
Author SHA1 Message Date
修雨 bc332133a2 fix(ci): sync public interface baseline 2026-07-16 10:32:38 +08:00
修雨 e615bd433c fix: surface invalid sheet and todo targets (#623)
* fix: surface invalid sheet and todo targets

* docs: record invalid target fixes

* fix: expose todo attachment listing schema

* fix: make Windows helper coverage portable

* test: run quality regressions in platform coverage
2026-07-16 10:24:58 +08:00
修雨 474ce88d47 fix(ci): harden CLI smoke and Schema compatibility gates (#629)
* fix(ci): harden PR gate enforcement

* fix(schema): allow compatible positional evolution
2026-07-16 09:59:21 +08:00
修雨 b35d67b811 Merge pull request #592 from DingTalk-Real-AI/feature/shortcut
feat(shortcut): declarative shortcut layer for DingTalk MCP (366 commands)
2026-07-16 09:07:38 +08:00
修雨 a14525ed1d fix(auth): isolate concurrent secure writes 2026-07-16 00:45:21 +08:00
修雨 816c356bbf docs(changelog): record shortcut command layer 2026-07-16 00:35:47 +08:00
修雨 f28dd6ee07 test(event): wait for personal source before reading logs 2026-07-16 00:28:28 +08:00
修雨 765338eb5b fix(audit): initialize writer at execution boundary 2026-07-16 00:16:39 +08:00
修雨 0bd767a3fe fix(ci): serialize authoritative coverage measurement 2026-07-15 23:56:31 +08:00
修雨 42627e769e fix(ci): keep platform coverage profiles bounded 2026-07-15 23:21:49 +08:00
修雨 3b22bb4994 fix(lint): normalize agent hint error text 2026-07-15 23:18:03 +08:00
修雨 6c192c2bf4 Merge remote-tracking branch 'origin/main' into codex/fix-pr-592-merge-gates
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
2026-07-15 23:10:37 +08:00
修雨 be5ce782b6 fix(shortcut): close review and CI gaps 2026-07-15 23:05:04 +08:00
修雨 e1a50f08a6 Merge pull request #624 from LastdianXuan/codex/sync-wukong-sheet-import
feat: sync Sheet import and Aitable workflow writes
2026-07-15 22:55:03 +08:00
修雨 d14ce3c8d2 docs(changelog): record sheet import and workflow writes 2026-07-15 22:45:18 +08:00
SCzheng b876b9b4ae Merge pull request #626 from sczheng189/codex/optimize-policy-script-builds
refactor(policy): reuse built binaries
2026-07-15 18:53:48 +08:00
张卓澎 82c6bcfcbf test(windows): avoid POSIX permission assumption 2026-07-15 18:31:56 +08:00
张卓澎 48a79b17c6 fix(sheet): expose import action to agent schema 2026-07-15 18:21:30 +08:00
Dennis 9b44eeb5b0 Merge origin/main into feature/shortcut 2026-07-15 17:38:58 +08:00
Dennis 6de77f4c34 docs: present shortcut catalog without hidden release wording
Replace release-hidden terminology with a generated public shortcut catalog, remove include-hidden discovery, and keep real-test followups as an internal CR artifact.
2026-07-15 17:02:42 +08:00
Dennis 00f1379874 docs: integrate visible shortcuts into skills
Generate product skill shortcut sections from the shortcut registry and release-hidden list so agents see the current public shortcut surface instead of only a hidden-command warning.
2026-07-15 16:54:21 +08:00
Dennis 8687d68567 feat: gate unverified shortcuts for release
Hide shortcuts that did not pass real testing from public help/list discovery while keeping commands available for internal retest.

Record real shortcut inputs/outputs, backend issue summaries, next-release hidden list, and refresh skill guidance to mirror the lark-cli shortcut integration pattern.
2026-07-15 16:45:51 +08:00
张卓澎 bf74159737 fix(windows): make helper coverage tests portable 2026-07-15 16:34:45 +08:00
zhengyubai 3ba0b90f9e refactor(policy): reuse built binaries 2026-07-15 16:58:43 +09:00
张卓澎 c2a6ce01aa feat(aitable): sync workflow create and update 2026-07-15 15:32:01 +08:00
张卓澎 09a300867c feat(sheet): sync workbook import from wukong 2026-07-15 15:31:08 +08:00
修雨 73e010a992 fix: make Gitee release sync resilient (#622)
* fix: make Gitee release sync resilient

* fix: address Gitee sync review feedback

* fix: bound the full Gitee sync path
2026-07-15 15:28:04 +08:00
SCzhengand修雨 809d026b7e ci: add CLI compatibility and PR quality gates (#602)
* ci(interface): 添加接口完整性和CLI烟雾测试检查

- 在Makefile中新增interface-integrity、update-interface-baseline、cli-smoke和mock-mcp-smoke目标
- 实现接口基线脚本以比较CLI公共命令树与基线文件
- 新增脚本确保二进制文件可渲染所有顶层命令的帮助信息
- 添加mock-mcp-smoke测试验证HTTP和stdio MCP请求/响应传输
- 在GitHub Actions CI工作流中加入interface-integrity、cli-smoke和mock-mcp-smoke检查
- 新增AI行为检查工作流,限制AI生成PR的改动范围和禁改保护文件
- 文档中补充PR质量门禁要求及接口变更流程说明

* feat(policy): 增加多项兼容性和完整性检查

- Makefile中新增reset-interface-baseline、schema-compatibility、update-schema-baseline、skill-command-integrity等目标
- CI流程新增schema-compatibility和skill-command-integrity步骤
- 文档中详细说明接口兼容性基线更新和重置流程及schema基线注意事项
- 实现interface-baseline工具支持兼容性重置和合并,多项接口兼容性检查逻辑完善
- 新增schema-compat工具用于标准化schema列表及兼容性检查与合并
- 新增skill-command-integrity检查确保技能中引用命令存在
- 为interface-baseline和schema-compat添加单元测试覆盖基本兼容性规则

* test(skill-command-check): 增加命令解析和解析结果测试用例

- 补充对 parseReference 函数的边界情况和跳过条件的测试
- 新增 resolveCommandReference 函数测试,覆盖有效、无效及跳过场景
- 增加 isPlaceholder 函数的测试,验证占位符识别准确性

refactor(skill-command-check): 优化命令路径解析和验证逻辑

- 使用 resolveCommandReference 统一处理命令解析结果,清晰区分有效、无效和跳过情况
- 新增 commandResolution 类型及常量,提升代码可读性和扩展性
- 调整 parseReference 增加对 shell 组合符 “ & ” 的跳过处理

docs(mono): 更新最佳实践和产品文档命令示例

- 优化《best_practices/07-minutes.md》中行动项与摘要拉取示例命令,提升准确性
- 修改产品文档中 ALIDOC 表格数据和多维表格记录相关命令,命令路径更规范统一
- 同步多端技能文档,确保命令示例一致且正确

* ci: check interface against PR merge-base

* feat(policy): enforce CLI contract compatibility

* ci: enforce PR coverage thresholds

* ci: add fail-closed CI gate

* ci: adapt schema compatibility gate to runtime catalog

* fix(ci): close schema compatibility gate gaps

* test(auth): skip POSIX mode assertion on Windows

* test(auth): scope POSIX file-backend checks

* fix(ci): enforce native platform coverage

* test(ci): make skill paths portable

---------

Co-authored-by: 修雨 <huyizhou.hyz@alibaba-inc.com>
2026-07-15 14:53:34 +08:00
修雨 3e9e76df2c feat: add stable and beta Homebrew channels (#613)
* feat: add stable and beta Homebrew channels

* fix: align beta formula with tap conventions

* fix: use dedicated token for Homebrew PRs

* chore: minimize release token permissions

* docs: record Homebrew token setup

* docs: keep Homebrew automation token long-lived

* fix(verify): assert channel versions and prove homebrew coexistence

The six-channel verifier previously ran `dws version` without comparing
it to the version each channel advertises, so a stale or wrong binary
still reported PASS. It also uninstalled stable before installing beta,
which could not prove the keg-only beta coexists with stable.

- smoke() now takes an expected version and fails the channel on mismatch
- npm/homebrew derive the expected version from the package manager;
  curl/upgrade derive it from the latest GitHub release tag
- homebrew installs keg-only beta while stable stays installed, then
  asserts stable's version, binary SHA and PATH link are unchanged
- cleanup uninstalls both script-installed formulae, still refusing to
  touch a pre-existing user install
- add regression tests for version assertion and coexistence semantics

* style(formula): satisfy brew style for stable and beta formulae

- reword desc so it no longer starts with the formula name
- drop the unnecessary `require "fileutils"` and use the mixed-in cp_r
  instead of the FileUtils. qualifier

* fix(verify): compare channel versions exactly

* fix(homebrew): keep generated formulae style-clean

* fix(homebrew): sync formulae with latest releases
2026-07-15 10:16:26 +08:00
修雨 4e59f9aa7a docs(changelog): seal v1.0.52 release notes (#619) 2026-07-14 23:04:01 +08:00
修雨 047ac54afe fix(connect): forward complex message payloads (#612)
Remove content-shape and message-type attachment filtering, recover forwarded unknown attachments, and preserve original media across all agent backends.
2026-07-14 22:31:21 +08:00
修雨 9a78a6494a Merge pull request #618 from DingTalk-Real-AI/codex/sync-wukong-im-read-results
feat(im): sync Wukong read-result semantics
2026-07-14 22:31:07 +08:00
修雨 73bf77d479 feat(im): sync Wukong read-result semantics 2026-07-14 19:04:05 +08:00
修雨 a98ae9c6cf Merge pull request #598 from typefield/feat/schema-on-main
feat(schema): add stable Agent command catalog
2026-07-14 17:26:12 +08:00
玉澜andCursor 918c73f418 docs(changelog): record stable 22-product Agent catalog for #598
Document the embedded Schema catalog delivery under Unreleased Added so
the PR documentation gate matches the shipped surface.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 17:11:44 +08:00
玉澜andCursor ef3c2feafb feat(schema): cover audit export/tail/verify from #555
Merge upstream main and publish the three public audit leaves into the
CommandRegistry, metadata/selection hints, and regenerated Catalog so
reverse completeness stays green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 16:54:29 +08:00
玉澜 0a7b6d8406 Merge upstream/main into feat/schema-on-main
Bring in #555 audit export/tail/verify public leaves for schema completeness.
2026-07-14 16:36:01 +08:00
玉澜andCursor b2561388fe fix(schema): keep Cobra hard-required as required projection floor
Stop letting manual/hint overlays project MarkFlagRequired flags as
optional; add final payload regression and gofmt the disposition test.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 15:27:23 +08:00
SCzheng da30780684 Merge pull request #555 from DingTalk-Real-AI/feat/audit-log-v2
feat(audit): implement user operation audit log
2026-07-14 14:54:44 +08:00
修雨 96986dfbff style(audit): gofmt trailing newline in audit_runtime_test.go
Fixes the Lint (Format Check) CI failure introduced by the previous
commit; gofmt flagged a trailing blank line at EOF.
2026-07-14 14:35:29 +08:00
修雨 27c3449036 fix(audit): drain forwards on error exit, fail CSV on corrupt JSONL
Address second-round review on PR #555:

- Move CloseAuditSink into the unconditional Execute defer so async remote
  forwards are drained on BOTH success and failure paths. Cobra skips
  PersistentPostRunE when RunE returns an error, which previously dropped
  in-flight forwards for failed commands. Make CloseAuditSink idempotent via
  sync.Once so the success-path hook and the defer can both call it.
- CSV export now returns a "文件:行号" error on malformed JSONL instead of
  silently skipping the line and exiting 0.
- Add regressions: TestCloseAuditSinkDrainsOnErrorPath (error-path drain),
  TestExportCSVFailsOnMalformedJSON (corrupt JSONL visible), and
  TestAuditIdentityReresolvesOnProfileSwitch (per-profile Actor via an
  injectable token loader seam).
2026-07-14 14:22:13 +08:00
玉澜andCursor e9cd8c9ad9 fix(schema): align event.stop confirmation with runtime --yes gate
Catalog safety now matches the existing CLI confirmation requirement so
Agent metadata and TestEventRegistry stay consistent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 14:16:12 +08:00
玉澜andCursor 5856a897d1 feat(schema): split human hints into metadata and selection
Own safety/gates/parameters in metadata/ and Agent prose in selection/,
drop the monolithic Manual file, and keep confirmation aligned with
per-tool runtime_gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 13:40:09 +08:00
修雨 d0787ce8ee fix(audit): stateless hash chain, waitable forwarder, profile actor, observability
Address PR #555 review:
- chain: derive prev_hash from file tail under cross-process flock, drop the
  global .chain sidecar so per-day files stay independently verifiable and
  concurrent dws processes cannot fork the chain
- forward: track async forwards with WaitGroup and add bounded Close(ctx) so
  in-flight deliveries are not dropped on process exit
- actor: resolve Actor from the active runtime profile (profile-keyed cache)
- observability: BuildSink returns init errors; write/forward failures reported
  to file log and to stderr when DWS_AUDIT_DEBUG is set
- cli: reject `audit tail --lines` < 1; check CSV writer/flush errors
- wire CloseAuditSink into PersistentPostRunE
- add regression tests for cross-date/cross-process chain, forwarder
  wait/timeout, init-failure, tail validation, CSV export
2026-07-14 11:56:09 +08:00
玉澜andCursor 363ca3de9b feat(schema): curate agent selection hints from live MCP and runtime gates
Rewrite use_when/avoid_when/examples with live dws schema plus Skill/Cobra
review, expand runtime_gates to 70 confirmed commands, and regenerate catalog.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 11:37:47 +08:00
Dennis fde008a25d fix(shortcut): address review safety notes 2026-07-14 11:20:55 +08:00
玉澜andCursor 987273f32b feat(schema): align confirmation with runtime via index+products hints
Make agent hints authoritative for confirmation by loading
internal/cli/schema_hints/index.json + products/*, and gate catalog
user_required to the reviewed runtime_gates set.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-14 10:33:49 +08:00
修雨 32c1d772de fix(root): expose audit command in help and reserve it from plugins 2026-07-14 10:30:05 +08:00
修雨 39b3003e2f Merge branch 'main' into feat/audit-log-v2 2026-07-14 10:21:15 +08:00
玉澜 f423031074 ci: allow full schema race gate to finish 2026-07-14 08:24:41 +08:00
玉澜 2879683cad fix(schema): enforce final delivery and runtime contracts 2026-07-14 08:11:27 +08:00
玉澜 878bafe55d perf(schema): keep delivery gates within race budget 2026-07-14 01:56:03 +08:00
玉澜 114c47b62d test(event): align restart hint with safe stop flow 2026-07-14 01:30:59 +08:00
玉澜 6d97bf7204 Merge upstream/main into feat/schema-on-main
Complete the registry-first Schema delivery invariants, bind the event command surface, and preserve the event subprocess contract from main.
2026-07-14 01:25:05 +08:00
玉澜 06cea56e92 fix(schema): close resolver and runtime contract gaps 2026-07-14 00:06:37 +08:00
玉澜 1f2b992e9c fix(schema): align capability contracts and delivery 2026-07-13 22:51:38 +08:00
SCzheng 43798de088 fix(connect): preserve rich text image attachments (#606) 2026-07-13 22:13:56 +08:00
Ari c4d8987f6c feat(event): AI-subprocess contract and cobra-synthesized schema (#609)
Align `dws event consume` with an AI-subprocess contract an orchestrator
can drive deterministically, and expose a machine-readable input schema
for event commands via `dws schema`.

Subprocess contract:
- Fixed stderr ready line `[event] ready event_key=<key> bus_pid=<pid>`;
  block on it instead of sleeping.
- Final `[event] exited — received N event(s) in Xs (reason: ...)` line;
  exit 0 on controlled exit, non-zero and no exited line on failure.
- stdin-EOF graceful shutdown, armed only for a pipe stdin on an
  unbounded run; an interactive TTY and `< /dev/null` never trigger it.
- Ownership-based subscription cleanup: a run-created subscription is
  unsubscribed on any clean exit while a --subscribe-id-reused one is
  kept (--ephemeral still forces cleanup). Forward --profile to the
  detached bus so non-default orgs resolve the right credentials, and
  surface the child's real startup error over the ready pipe.

Schema:
- `dws schema "event consume"` (or event.consume) synthesizes a flat,
  machine-readable schema from the command's cobra flags:
  {description, path, source:"cobra",
  parameters{<flag>:{type,required,description,default?}}} plus an
  `arguments` array for positional inputs. Intermediate nodes list
  subcommands. Inherited global flags and hidden internal flags are
  excluded so the schema describes just that command.
- Reusable registry (cobraSchemaRoots); event is the first consumer and
  more command trees can opt in without further wiring.

Docs: mono + dingtalk-event skills document the contract and the two
schema surfaces; design notes in docs/event-subprocess-contract.md.
2026-07-13 22:08:22 +08:00
玉澜 fc415919d1 fix(ci): remove ripgrep dependency from schema policy 2026-07-13 21:15:56 +08:00
玉澜 125f0c8fe0 Merge remote-tracking branch 'upstream/main' into feat/schema-on-main
# Conflicts:
#	test/scripts/package_script_test.go
2026-07-13 21:09:27 +08:00
玉澜 55afc656ac fix(schema): validate agent example delivery 2026-07-13 20:33:56 +08:00
玉澜 f6c2ce655d refactor(schema): unify registry-first delivery 2026-07-13 19:49:40 +08:00
Aemeathand张卓澎 657d2c25e3 feat: sync open product command capabilities (#608)
Co-authored-by: 张卓澎 <zhuopeng.zzp@alibaba-inc.com>
2026-07-13 17:21:47 +08:00
johnand玉澜 9f7107b6bb ci: sign macOS releases with Apple Developer ID (#605)
* fix release upload of signed macOS assets

* ci: sign macOS releases with Developer ID

* fix release publication atomicity

* harden Developer ID release verification

* fix: run release script tests in CI

---------

Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
2026-07-13 17:10:23 +08:00
Dennis eb5569ca21 docs(shortcut): refresh lark capability comparison 2026-07-13 16:56:42 +08:00
Dennis b7c14c118f fix(shortcut): adapt tool callers to main interface 2026-07-13 16:08:33 +08:00
Dennis fb4cf70c93 Merge remote-tracking branch 'origin/main' into feature/shortcut 2026-07-13 16:05:41 +08:00
Dennis 890dfea477 fix(shortcut): harden orchestration and usage tracking 2026-07-13 16:04:47 +08:00
修雨 bfd48b6a71 fix: preserve macOS auth across keychain mode changes (#597)
* fix: preserve auth across macOS keychain modes

* docs(auth): clarify per-profile recovery

* fix(auth): add safe macOS keychain migration

* ci: add native Windows auth coverage

* ci: scope Windows checks to auth paths

* fix(auth): address keychain review boundaries
2026-07-13 15:27:39 +08:00
玉澜 d41ea586bf fix(schema): enforce catalog and interface completeness 2026-07-13 14:01:26 +08:00
玉澜 f77232d7c1 feat(schema): add agent-friendly manual hints 2026-07-13 13:41:30 +08:00
玉澜 31faf7205b docs: add repository agent guidance 2026-07-13 11:53:18 +08:00
玉澜 45e0423d46 fix(schema): enforce command and safety completeness 2026-07-13 11:50:20 +08:00
玉澜 1b70d8f3f2 Merge remote-tracking branch 'upstream/main' into feat/schema-on-main 2026-07-13 11:05:19 +08:00
玉澜 a6f309d011 fix(schema): lazily load embedded catalog 2026-07-13 11:05:08 +08:00
修雨 390b6115bf fix(connect): harden daemon restart lifecycle (#599) 2026-07-12 23:11:08 +08:00
玉澜 a6b2972a1e feat(schema): review sheet range and filter agent semantics
Add explicit reviewed Agent hints for high-frequency sheet range/filter/filter-view, condition-format and dropdown tools. Replace generic avoid_when with concrete read/write/clear/style/filter-view disambiguation, tighten destructive operations, and regenerate schema metadata/catalog.

Validated with drift/catalog gates and go test ./internal/cli ./internal/app ./internal/generator/... .
2026-07-11 17:39:57 +08:00
玉澜 1e0a171ceb feat(schema): review attendance agent semantics
Add explicit attendance Agent review hints for all 38 attendance tools, replacing template avoid_when with business-specific selection guidance and marking them reviewed. Tighten high-impact attendance writes such as boss-check and settings/balance updates with high risk and user confirmation.

Regenerate schema metadata/catalog and update parameter binding hash. Drift/catalog gates and key schema tests pass.
2026-07-11 17:32:39 +08:00
玉澜 cb4d1c215c feat(schema): generate catalog from live Cobra tree without fallback
Stop registering runtime catalog fallback commands and make command-surface generation use the real Cobra tree directly. Regenerate schema surface, agent metadata and catalog from executable commands (20 products / 537 tools), add runtime-surface completeness hints, and update catalog gates/tests to use dynamic counts instead of old 504/21/461 constants.

This makes schema describe the actual executable CLI surface; drift/catalog gates and go test ./internal/cli ./internal/app ./internal/generator/... pass.
2026-07-11 16:07:23 +08:00
玉澜 538754bbba feat(schema): sharpen aitable view summaries and sibling disambiguation
Add explicit reviewed summaries for aitable view get/update subcommands so Agents
can distinguish filter, sort, group, visible-fields, aggregate, card and other
view operations. Regenerate sibling-disambiguation avoid_when entries from the
new summaries, making cross-tool guidance precise instead of generic.

Results: 395/504 tools carry sibling-command disambiguation and reviewed coverage
rises to 104/504. drift/catalog gates and go test ./internal/cli pass.
2026-07-11 14:31:38 +08:00
玉澜 c2010b912b chore(schema): drop accidentally committed dwsbin binary and ignore it 2026-07-11 14:03:10 +08:00
玉澜 cf8cf95087 feat(schema): add sibling-command disambiguation to avoid_when
Add skills/mono/schema-hints/sibling-disambiguation.json: for each multi-segment
command sub-group (aitable view update, sheet range, chat message, ...), append
explicit cross-referencing avoid_when entries pointing agents to the correct
sibling command. Regenerate embedded agent metadata + catalog: 395/504 tools now
carry sibling disambiguation, improving tool-selection beyond template-only
avoid_when. drift/catalog gates and go test ./internal/cli pass.
2026-07-11 14:02:05 +08:00
玉澜 f86d10ae63 feat(schema): add --compact mode and update SKILL.md schema guide
- Add --compact flag to schema command (canonical.go)
- Implement stripSchemaPayloadCompact to recursively remove provenance/
  debug/redundant fields (runtime_schema.go)
- Strip 27 top-level keys (agent_metadata_source, agent_source_refs,
  interface_ref, primary_cli_path, etc.) and 3 per-parameter keys
  (interface_description, interface_type, property)
- Add 3 tests covering leaf/overview/product compact modes
- Replace stale SKILL.md schema section with progressive query guide,
  compact field reference, and schema-vs-help decision table
- Regenerate schema artifacts (make generate-schema)

Size reduction:
  leaf: 9.5KB -> 6.0KB (36%)
  --all: 644KB -> 414KB (36%)
2026-07-11 13:41:19 +08:00
玉澜 3f3ece933b feat(schema): complete reviewed agent metadata 2026-07-11 12:23:55 +08:00
玉澜 3fac462410 fix(schema): keep defaulted pagination optional 2026-07-11 11:42:06 +08:00
玉澜 753866867f feat(schema): complete catalog contract and smoke gates 2026-07-11 11:21:44 +08:00
玉澜 a62bcdf460 fix(schema): audit fallback parameter bindings 2026-07-11 10:42:02 +08:00
玉澜 561525a18b feat(schema): generate stable agent command catalog 2026-07-11 10:28:10 +08:00
玉澜 e1ea573247 feat(schema): align dws schema with prior branch and GWS/Lark contract
Serve the versioned embedded Command Catalog (21 products / 504 tools) from
NewSchemaCommand instead of only the live tree, matching the prior branch's
release behavior and the GWS flat-leaf / Lark stable-canonical contract. Add
--all and route output through internal/output for --format/--jq/--fields.
Port schema_catalog_test.go asserting 504/21 embedded catalog integrity.
Helper subtree and live Cobra tree remain as fallbacks.
2026-07-11 01:58:36 +08:00
玉澜 eb9e6be944 merge feat/schema-gws-flat into upstream static-endpoint schema branch
Consolidate the prior schema branch (old discovery-based architecture) into the
upstream-based dynamic-schema implementation. Merged tree keeps the upstream
static-endpoint architecture with dynamic schema; old discovery/generator/compat
packages are not carried over (incompatible with upstream, superseded by the
live-tree dynamic schema). Old schema data assets (agent metadata, destructive
safety annotations, conference metadata) remain present via the ported runtime.

Brings origin/feat/schema-gws-flat history in, so pushing is a fast-forward.
2026-07-11 01:46:18 +08:00
玉澜 ec59f7b042 feat(schema): implement dynamic schema on static-endpoint architecture
Restore dynamic dws schema on top of upstream static-endpoint runtime
(v1.0.52) without re-introducing service discovery:
- port schema runtime (runtime_schema/schema_catalog/schema_agent_metadata/
  schema_hints) + embedded agent & interface metadata + ir data structures
- ir/catalog.go: drop discovery-dependent BuildCatalog, keep runtime types
- canonical.go NewSchemaCommand: build schema from the live Cobra tree via
  runtimeSchemaPayload instead of the stub
- add schema_support.go and design doc docs/schema-dynamic-endpoint-design.md

go build ./... passes; go test ./... 44 packages pass (only unrelated
post-goreleaser packaging tests fail with a known tar format issue).
2026-07-11 01:26:22 +08:00
玉澜 63c0b26cf6 feat: add conference agent metadata (summary/effect/reviewed)
Add skills/mono/schema-hints/conference.json annotating all 33 conference
meeting-control tools with agent_summary, effect and reviewed=true. Mark
end-meeting-for-all as risk=high + confirmation=user_required; mute-all and
cloud-record start/stop as risk=medium.

Coverage: missing agent_summary 81->48, missing effect 173->140,
reviewed=true 4->37. Drift/catalog gates, go test and 560-case smoke pass.
2026-07-11 00:04:54 +08:00
玉澜 5004fcd285 feat: add destructive-operation safety metadata to agent schema
Annotate 34 high-risk tools via skills/mono/schema-hints/destructive-safety.json
(30 destructive + 4 disable) with risk=high and confirmation=user_required, and
fix mergeToolMetadata effect precedence (effectSourceRank) so explicit hints
override command-verb inference. Regenerate embedded agent metadata and catalog.

risk=high coverage 22->56, effect=destructive 29->48; drift/catalog gates,
go test, and 560-case schema smoke all pass.
2026-07-10 23:50:53 +08:00
修雨 fc9acb9007 fix: align smart category args and runtime network errors (#591)
* fix: align smart category args and remove eval fixtures

* fix: classify runtime network failures

* fix: validate smart category inputs
2026-07-10 21:40:36 +08:00
aa6abc5ed6 feat(event): add personal event subscriptions (#589)
* dws event

* fix consume fail

* test: add stream ticket injection probe

* feat: add portal ticket stream mode

* user event

* fix: allow portal ticket normal without app secret

* event

* user event

* eventType filter

* refactor(event): 优化IPC端点路径处理和改进相关测试

- 用dwsevent.IPCEndpoint替代原先根据GOOS判断的路径逻辑
- 新增event包实现Unix socket路径长度限制及长路径fallback机制
- 添加endpoint_test.go覆盖路径短长及唯一性的单元测试
- 修改busctl模块使用统一的IPC端点获取方法,避免重复实现
- transport_unix.go新增checkSocketPath函数检查路径长度,防止EINVAL错误
- 在监听和连接Unix socket时加入路径限制检查,提升错误明晰度
- 去除多个文件中无用的runtime导入,简化代码依赖

* opt

* event skill

* default value

* install script event

* fix: remove subscribe id event fanout filter

* fix(personal): 修正指定发送人消息描述错误

* more im event

* filter subId

* fix: align personal event schema with stream payload

* fix: avoid duplicate app helper name

* feat: simplify personal event schemas

* feat: simplify event schema output

* docs: refine dingtalk event skill references

* feat: align personal event consume flags

* fix event stop and status visibility

* hide app event public entrypoints

* hide incomplete personal sender event

* remove external event reference comments

* chore(event): prepare official release

* fix(event): harden personal stream lifecycle

---------

Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
Co-authored-by: zhengyubai <zhengyubai618@gmail.com>
2026-07-10 17:54:43 +08:00
修雨 ea6fd16d11 chore(changelog): prepare v1.0.51 stable (#595) 2026-07-10 17:35:12 +08:00
玉澜 eec64bdf35 fix: align schema aliases and one-of coverage 2026-07-10 17:13:29 +08:00
玉澜 ddad2f648c fix: align agent schema parameter contracts 2026-07-10 15:12:49 +08:00
玉澜 391e761b59 fix: stabilize agent schema metadata 2026-07-10 13:42:10 +08:00
玉澜 a15fb19fd2 test: retire obsolete discovery compatibility suite 2026-07-10 13:06:24 +08:00
玉澜 70107e008f feat: embed agent-optimized schema metadata 2026-07-10 12:53:51 +08:00
DennisandClaude Opus 4.8 b9f2733821 feat(app): assemble and wire shortcut commands into the CLI
builtin blank-imports every service + smart package so their registrations run,
exposes Commands(), and provides the zero-side-effect coverage suite
(TestAllShortcutsAssemble / TestAllToolLiteralsAreReal / TestNoDuplicateCommands
/ TestAllHaveIntent). legacy loads user YAML shortcuts then merges built-in
shortcut leaves into the helper command tree; root wraps the tool caller with the
usage recorder and registers dws shortcut.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:04:10 +08:00
DennisandClaude Opus 4.8 83543b20df feat(shortcut): P2 usage tracking (opt-in) + user-defined YAML shortcuts
Optional high-frequency distillation: a recording tool-caller logs each MCP
call's shape (not values; sensitive/free-text redacted) to ~/.dws/usage.jsonl —
OFF by default, opt-in via DWS_USAGE_TRACKING=1. Powers dws shortcut
list/stats/suggest/add. userdef compiles ~/.dws/shortcuts/*.yaml into registered
shortcuts at runtime (conflicts with built-ins skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:04:10 +08:00
DennisandClaude Opus 4.8 4e4705c673 feat(shortcut): smart orchestration layer (68 shortcuts)
Multi-step / intelligent shortcuts under internal/shortcut/smart: name→ID
resolvers (user/base/table/dept/space), name-based actions (chat +dm/+broadcast/
+group-members, todo +assign, calendar +book with rollback/+free/+invite/
+suggest-time), time & self intelligence (calendar +today/+tomorrow/+week/
+next-event/+my-free and +conflicts/+free-slots scheduling intelligence),
convenience reads (contact +me, oa +pending/+done-approvals, todo +due-today/
+related-tasks, mail +recent-mail/+find-mail-user, attendance +this-month) and
aggregation (minutes +detail, aitable +record-share-links). Projections hardened
against real DingTalk responses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:04:10 +08:00
DennisandClaude Opus 4.8 cd652bf9ab feat(shortcut): 298 one-to-one MCP tool wrappers across 16 services
Declarative 1:1 shortcuts (dws <service> +<command>) wrapping DingTalk MCP tools
with named flags, required/enum validation, risk confirmation and a
natural-language Intent; list/read commands add clean output projection. Scoped
to tools the helper command layer does NOT already expose, plus a handful that
add projection — the redundant re-wraps were pruned. Tool names/params are taken
verbatim from internal/helpers ground truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:04:10 +08:00
DennisandClaude Opus 4.8 c5463424be feat(shortcut): declarative shortcut framework
A declarative Shortcut{Service,Command,Product,Risk,Flags,Validate,Execute}
struct compiled into cobra commands by the runner. RuntimeContext offers
CallMCP (terminal, prints), CallMCPData (multi-step, returns parsed data,
cross-server) and Output (projection honouring --format/--jq/--fields), plus
cross-field validators (MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/
RequireAll) and a Register/Commands registry. helpers exports
CallMCPToolTextOnServer so multi-step shortcuts can consume intermediate results.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 10:04:10 +08:00
修雨 4c43108bdf sync wukong hardcoded command additions
sync wukong hardcoded command additions

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-07-09 21:28:57 +08:00
修雨 5e9a920b76 fix(connect): prevent agent mid-turn blocking
fix(connect): prevent agent mid-turn blocking

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-07-09 21:28:35 +08:00
玉澜 15e0851e06 fix: cover attendance schema smoke cases 2026-07-09 16:11:10 +08:00
玉澜 f1ca55c649 fix: make schema smoke mail search deterministic 2026-07-09 13:52:08 +08:00
玉澜 4440479c5c feat: align runtime schema smoke validation 2026-07-09 11:32:25 +08:00
xuanandshangguanxuan.sgx 36b0528d90 fix: default pat chmod grants to permanent (#584)
* fix: default pat chmod grants to permanent

* docs(changelog): note pat chmod permanent default

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-09 11:28:45 +08:00
xuanandshangguanxuan.sgx a2201b4ab4 test(pat): remove external example auth URL trigger (#583)
* test(pat): remove external example auth URL trigger

* test(app): prevent browser launches during tests

* test(pat): build auth URL fixture as JSON

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-09 11:28:40 +08:00
修雨 181cdf4a03 Merge pull request #578 from LastdianXuan/fix/dek-readonly-keychain-status
fix: keep keychain reads side-effect free
2026-07-08 18:46:33 +08:00
修雨 818b8b29e3 chore(changelog): add v1.0.50 release notes (#580)
Covers PR #575 (global --jq/--fields honored on product commands,
skill --dry-run preview, sheet batch-style JSON mode, skill docs
alignment) and the exported cmdutil leaf-merge / provenance helpers.
2026-07-08 14:49:25 +08:00
Ari 109ad13844 fix: honor global --jq/--fields on product commands; round-2 QA fixes (#575)
* fix: honor global --jq/--fields on product commands; round-2 QA fixes

Make the global --jq / --fields output filters actually work for the
product (MCP) commands. The helper Formatter used by every product
command ignored them, so they were silent no-ops there (they already
worked for `dws api`). Expose Fields()/JQ() on the ToolCaller interface
and apply the existing output.WriteFiltered path in the helper
Formatter's PrintJSON. The handful of bespoke utility commands
(auth/config/profile/...) still encode directly and are documented as
such.

Additional CLI fixes surfaced by the second real-machine QA pass:
- sheet write-image: emit clean JSON under --format json (suppress the
  progress lines that leaked onto stdout, same as media-upload/export)
- sheet range batch-set-style: under --format json, collect per-item
  results into a single JSON object instead of printing N separate ones
- chat download-media: create the output directory when missing and
  strip URL-encoded path separators from the inferred filename so the
  file actually lands instead of failing on a missing subdirectory
- pat chmod, aitable, sheet, chat, attendance: correct --help text
  (real scope names, non-existent subcommands, flag requiredness,
  alxs -> axls typo)

Helper scripts (mono and multi):
- minutes_extract_todos: parse dingtalkTodoList/actions (there is no
  todos key), so todos are no longer silently dropped
- sync the multi copies of chat_export_messages / chat_history_with_user
  (were crashing with AttributeError), minutes_list_parse /
  minutes_recent_summary, and calendar_free_slot_finder to the fixed
  mono versions

Skill docs (mono and multi): correct return-structure keys, flag names,
deprecated command routing (doc download -> drive download), enum values
and server-side limitations across products; update the global
reference to note --jq/--fields now apply to product commands.

* fix(skill): make skill setup --dry-run a no-op preview; doc/help fixups

skill setup ignored the global --dry-run flag and always wrote the skill
files (overwriting an existing install). Short-circuit into a preview
that lists the source, target dirs and selected sub-skills without
touching the filesystem.

Also correct a few doc/help mismatches found in the round-3 health check:
- attendance vacation balance/records quick-reference examples were
  missing the required --leave-code flag
- mail mailbox list --help described the returned field as "mailboxes"
  but the real field is "emailAccounts"

* docs: clarify --fields projects top-level/list keys, use --jq for nested

* docs: drop QA voice ("真机") and don't state env-specific quirks as absolute rules

The QA-driven doc/comment edits leaked test-process narration ("真机实测")
and this environment/account's quirks stated as universal rules into the
skill files, which are general-purpose instructions for any org/account.
Strip the "真机" narration everywhere; reword environment-specific findings
(PUBLIC sharing disabled by org policy, transient 1002, sender-open-dingtalk-id
behaviour) from absolute bans into conditional hints; keep genuinely
universal command behaviour, just without the QA voice.
2026-07-08 14:06:57 +08:00
修雨 5ac5fcbf16 Merge remote-tracking branch 'origin/main' into feat/audit-log-v2
# Conflicts:
#	internal/helpers/devapp_connect.go
2026-07-08 13:59:54 +08:00
张卓澎 fd6bbd928e fix: keep keychain reads side-effect free 2026-07-08 11:23:44 +08:00
张卓澎 67417d3fb1 fix: diagnose macos keychain auth failures 2026-07-08 11:23:44 +08:00
修雨 91dfc8b926 fix: export command merge helpers 2026-07-08 10:47:31 +08:00
修雨 b794d802f2 release: prepare 1.0.49 stable (#574) 2026-07-08 00:14:57 +08:00
修雨 e6c1dfe15c Merge pull request #570 from DingTalk-Real-AI/fix/release-publish-unblock
ci: unblock npm release from Gitee mirror
2026-07-07 23:46:21 +08:00
修雨 32d32cd827 Merge pull request #572 from audanye-sudo/fix/qa-optimize-6products
fix: resolve real-machine QA findings across CLI, scripts and skill docs
2026-07-07 23:44:00 +08:00
qinze a65d6f23ec fix: resolve real-machine QA findings across CLI, scripts and skill docs
Fix CLI command bugs surfaced by full real-machine QA:
- aitable: make chart/dashboard share update --enabled a string flag so
  "--enabled false" disables instead of silently enabling (bool flag +
  space-syntax help example inverted the action); clarify chart update
  requires --config; make form get filter by view-id client-side so it
  returns a single form; drop inline // comments from chart JSON examples
- chat: resolve conversation-info --user to openDingTalkId, register
  --id/--conversation-id/--chat aliases; cap list-all-conversations
  --limit at 100 and reject larger values instead of silent truncation;
  detect webhook errcode failures instead of wrapping them as success;
  remove duplicate group/members subcommand registration in help
- contact: register --dept/--depts as the primary dept flags to match
  the RunE parsing (were only registered as --id/--ids)
- sheet: emit clean JSON for media-upload and export under --format json,
  suppressing progress lines that leaked onto stdout
- wiki: correct node create --type enum (drop unsupported asheet, add
  axls/able/appt/adraw/amind)
- ding: default message list --type to ALL since the server rejects an
  empty type

Fix helper scripts (mono and multi):
- aitable import/export flag names and tableId length regex
- mail search --limit, contact dept response keys and userInfo nesting
- attendance_my_record whoami compatibility, calendar_schedule_meeting
  event id unwrapping, drive_tree_list recursion via fileId, report
  scripts migrated off deprecated report list/detail

Sync skill docs (mono and multi) to real-machine behavior across all
products: command indexes, flag names, enums, return-structure keys, and
cross-product intent routing; annotate genuinely server-side limitations
and the no-op global --jq/--fields flags.
2026-07-07 23:38:23 +08:00
修雨 a838ae75a7 ci: unblock npm release from Gitee mirror 2026-07-07 22:23:16 +08:00
修雨 238f4256d3 ci: harden Gitee mirror synchronization
Serialize Gitee mirror runs, mirror tag events without touching main, and align Gitee release tags before uploading assets.
2026-07-07 20:48:40 +08:00
修雨 b83e6dc239 release: prepare 1.0.48 stable 2026-07-07 20:48:39 +08:00
修雨 a842560d71 ci: harden gitee mirror synchronization 2026-07-07 20:15:50 +08:00
修雨 d808843f75 feat: seal remove-discovery delivery beta
Merge sealed remove-discovery delivery beta with static endpoint runtime, legacy compatibility aliases, synced skills, yolo connect default, and beta upgrade track.
2026-07-07 19:19:20 +08:00
修雨 6623a6969d docs: sync skills and beta release guidance 2026-07-07 19:15:29 +08:00
修雨 a32d7985e6 refactor: switch to static endpoint delivery runtime 2026-07-07 19:07:03 +08:00
修雨 d3f8e9d712 style(helpers): fix gofmt formatting for devapp_connect and connect_daemon (#558)
Import ordering and struct field alignment were off since #548,
causing the CI format check to fail on main.
2026-07-06 17:21:44 +08:00
Evilsong7and南凇 043881e0e3 新增企业凭证hook&对应凭证登录检验未通过的报错处理 (#506)
Co-authored-by: 南凇 <tianjinsong.tjs@alibaba-inc.com>
2026-07-06 14:54:27 +08:00
修雨 4a717bd92f feat(audit): implement user operation audit log
- Add internal/audit package: Event struct, FileSink, date rotation, L1 hash chain, HTTP forwarding, 3-tier redaction
- Integrate with runner: emit audit event in executeInvocation defer
- Add dws audit tail/export/verify command group
- Register DWS_AUDIT* env vars in configmeta, enabled by default
2026-07-06 11:55:40 +08:00
玉澜 604ec5f50a Merge remote-tracking branch 'origin/feat/dws-event' into feat/dws-event 2026-07-06 10:04:19 +08:00
玉澜 27296ec426 fix: remove subscribe id event fanout filter 2026-07-06 10:04:12 +08:00
修雨 b63e1b4469 release: cut 1.0.47 — connector supervision + bot-to-bot @-mention (#548) 2026-07-06 00:46:47 +08:00
wxianfeng 6a38a168dd install script event 2026-07-02 20:41:46 +08:00
wxianfeng 9771053d81 default value 2026-07-02 20:14:30 +08:00
wxianfeng 10c0c5083e event skill 2026-07-02 19:42:10 +08:00
wxianfeng a0187b5297 Merge branch 'feat/dws-event' of github.com:wxianfeng/dingtalk-workspace-cli into feat/dws-event 2026-07-02 17:15:48 +08:00
wxianfeng 81991f1c07 opt 2026-07-02 17:14:55 +08:00
xianfeng wang 836670ef50 Merge pull request #24 from sczheng189/feat/dws-event
fix(event): unix socket 路径超长时 fallback 到短路径,修复深层配置目录下 bus 无法启动
2026-07-02 16:54:58 +08:00
zhengyubai 53ce0a8303 refactor(event): 优化IPC端点路径处理和改进相关测试
- 用dwsevent.IPCEndpoint替代原先根据GOOS判断的路径逻辑
- 新增event包实现Unix socket路径长度限制及长路径fallback机制
- 添加endpoint_test.go覆盖路径短长及唯一性的单元测试
- 修改busctl模块使用统一的IPC端点获取方法,避免重复实现
- transport_unix.go新增checkSocketPath函数检查路径长度,防止EINVAL错误
- 在监听和连接Unix socket时加入路径限制检查,提升错误明晰度
- 去除多个文件中无用的runtime导入,简化代码依赖
2026-07-02 17:25:56 +09:00
wxianfeng 78867f3601 eventType filter 2026-07-02 16:19:28 +08:00
修雨 bba94c0092 docs(changelog): cut 1.0.46 (#542) 2026-07-01 16:45:06 +08:00
wxianfeng 37438659e6 user event 2026-07-01 15:58:09 +08:00
wxianfeng 3c12c835a3 Merge branch 'feat/dws-event' of github.com:wxianfeng/dingtalk-workspace-cli into feat/dws-event 2026-07-01 14:22:06 +08:00
wxianfeng 389f83241f event 2026-07-01 14:21:36 +08:00
玉澜 3714adc2db Merge remote-tracking branch 'origin/feat/dws-event' into feat/dws-event
# Conflicts:
#	internal/app/event_command.go
2026-07-01 14:20:17 +08:00
玉澜 f37d0569a1 fix: allow portal ticket normal without app secret 2026-07-01 14:17:12 +08:00
wxianfeng 798b58bf3c fix conflict 2026-07-01 11:15:48 +08:00
wxianfeng d926bed3cc user event 2026-07-01 11:07:57 +08:00
xuanandshangguanxuan.sgx fe4a79283c fix(pat): keep agent code grants aligned (#536)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-01 10:16:59 +08:00
玉澜 5ac180d3dd feat: add portal ticket stream mode 2026-06-30 20:38:27 +08:00
玉澜 35e60407d3 test: add stream ticket injection probe 2026-06-30 15:33:17 +08:00
Ariand修雨 3ee5f13c62 docs: condense Key Services table and document multi-org profiles (#527)
* docs: condense Key Services table and document multi-org profiles

The Key Services section listed a per-service command count and an exhaustive
subcommand token dump plus a long description, which had drifted out of date
and was hard to scan. Condense it (EN + zh) to a lark-cli-style
Service / Command / Capabilities table with a one-line capability per service,
pointing to docs/command-index.md for the full listing.

Also document the multi-organization (profile) capability, which had no README
coverage: a collapsible section placed right after "Custom App mode (CI/CD,
ISV integration)" in Getting Started, covering auth login adding a profile,
profile list / switch, the global --profile one-shot flag, and the agent-
orchestrated cross-org read pattern (writes stay on the current org). Mirrored
in README_zh.md.

CHANGELOG: add a [1.0.45] entry describing the full multi-profile feature
(login / profile management / --profile / backward-forward compatibility /
skill docs) plus the persistence hardening (locking, atomic writes, corruption
recovery, safe legacy mirror, no cross-org token fallback).

* docs(changelog): note --ai-tag default-on (#524) in [1.0.45]

---------

Co-authored-by: 修雨 <huyizhou.hyz@alibaba-inc.com>
2026-06-29 19:20:46 +08:00
e32fa1535c feat(auth): support multi-profile login (#500)
* feat(auth): support multi-profile login

* fix(auth): complete multi-org profile acceptance

* feat(auth): 完成多组织 profile 验收

* docs(auth): 补充多组织 Ralph 验收材料

* feat(auth): 支持 auth switch TUI 切换 profile

* feat(auth): logout 默认清理所有组织

* feat(auth): login 默认新增组织授权

* feat(profile): 使用 profile switch 切换组织

* docs(ralph): 更新 profile switch 验收材料

* fix(profile): 展示全部可切换组织

* feat(profile): support multi-org switch tui

* chore(install): add branch source installer

* fix(profile): keep global profile out of tool params

* feat(profile): support csv multi-profile runtime

* ci: add multi-profile e2e workflow

* ci: run multi-profile e2e on all branches

* docs: document multi-profile e2e ci gate

* docs: remove multi-profile test cases from pr

* ci: harden multi-profile e2e gates

* fix(auth): serialize profiles.json RMW and harden multi-profile persistence

Wrap all profiles.json read-modify-write paths (profile switch/use/remove,
status marking, token save, logout) in the existing dual-layer lock via a new
withProfilesLock helper. Split each writer into a public (locking) entry point
plus a lock-free *Locked variant so the non-reentrant lock is never re-acquired;
the refresh path (oauth_helpers) and the load-path legacy migration now call the
lock-free saver to avoid self-deadlock.

Also: write profiles.json and the token marker via per-write random temp names
(uuid) to stop concurrent writers from corrupting a fixed .tmp file; quarantine
an unparseable profiles.json and rebuild an empty config so the CLI can
self-heal instead of locking out auth reset/logout; make DeleteAllTokenData
proceed even if profiles.json cannot be read; and stop SyncLegacyTokenMirror
from deleting the legacy mirror on a transient keychain read error.

* fix(auth): do not fall back to a different org's legacy token slot

When no explicit --profile is given, LoadTokenDataForProfile resolves the
current/primary profile and reads its per-corp keychain slot. If that slot
read failed, the code silently fell through to the legacy single token slot,
which after any drift between the legacy mirror and the current profile could
belong to a different organization. The command would then run as the wrong
org with no indication to the user.

Reproduction (conceptual):
  - profiles.json currentProfile = corpA
  - corpA's keychain slot is unreadable, legacy single slot still holds corpB
  - any read command (no --profile) silently used corpB's token

Fix: when a profile is resolved but its slot read fails and no --profile was
given, only fall back to the legacy single slot when its CorpID matches the
resolved profile (same org); otherwise return the original error instead of
acting as a different organization. The no-profile legacy path (pre-migration
installs with no resolved profile) is unchanged.

Tests:
  - Covered by the existing internal/auth suite under go test -race; the
    same-org fallback preserves the legacy-mirror case while the cross-org
    case now surfaces the read error.

* feat(skill): document multi-org profile usage and always ship dws-shared

The skills had no guidance on the multi-profile capability, so an agent would
treat the CLI as single-org: when a lookup missed in the current org it would
give up or ask the user instead of searching other logged-in orgs. The multi
skill set also referenced a `dws-shared` prerequisite that was never actually
installed, and the only multi-org hints lived inline in three product skills.

This adds, in source only:
- A "multi-org / profile" section in the mono SKILL.md (concept, commands,
  cross-org rule, aggregation, safety guardrails) plus a decision-tree entry,
  trigger conditions, and a corrected logout danger-table row (logout removes
  all orgs by default; removing the primary silently re-elects a new primary,
  confirm before removing the primary).
- A standalone skills/multi/dingtalk-profile skill mirroring the same content.
- A new skills/multi/dws-shared skill that carries auth, global flags and the
  multi-org rule, so every product skill's PREREQUISITE resolves and all
  read/search skills inherit the cross-org behavior without per-skill edits.
- Cross-org fallback notes on dingtalk-aisearch / chat / contact.

To guarantee the prerequisite actually ships, multi-mode install now force-
includes dws-shared even when --skill / --exclude narrows the set (no-op when
the source has no dws-shared, preserving older layouts).

Tests:
  - internal/app: TestP1SharedAlwaysIncludedWithSkillFilter installs with
    `-s aitable` and asserts dws-shared still lands in the destination;
    TestP1SharedNoopWhenAbsent guards the older-layout no-op.
  - go test -race ./internal/auth/... ./internal/app/... passes.

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: qinze <audanye@gmail.com>
2026-06-29 18:27:25 +08:00
修雨 79b8eda3b6 feat(chat): default --ai-tag on so dws-sent messages carry the AI badge
Per req 83667761 (奕皓): messages sent through dws should carry the
「通过AI发送」badge by default, transparently flagging AI/CLI-sent messages.

- `--ai-tag` default flipped false → true on `chat message send` / `reply`, so
  no flag / `--ai-tag` / `--ai-tag=true` all attach clawType (open edition
  `openClaw`); only `--ai-tag=false` omits it (send as the user). The switch name
  is unchanged. reply honors the same default (no longer leaks the wukong
  clawType).
- skill chat.md: concise rule — default-on, pass `--ai-tag=false` to disable.
- tests: default now asserts clawType present; added an `--ai-tag=false` opt-out
  case.
2026-06-29 18:16:20 +08:00
wxianfeng ea46132cf6 merge upstream main 2026-06-29 16:15:13 +08:00
修雨 be80790172 docs(changelog): add [1.0.44] — phantom guard, report contents-file, @file, sheet parity 2026-06-29 10:53:03 +08:00
修雨 2dbbca1ec9 docs(skill): align dws skill references with the real CLI
Every documented command / flag / example now matches `dws <svc> --help`:
- drop phantom commands (attendance class/group/vacation/..., contact label,
  ding message list/receiver-status) that map to undeployed tools.
- fix runtime-failure flags (mail --body→--content, doc/calendar/minutes
  pagination, chat send-by-bot @-flags, wiki member --users).
- route role/duty "who is responsible" queries to `aisearch person --dimension
  duty` instead of the removed `contact label`.
- realign the multi/dingtalk-report skill to entry submit / inbox list / outbox
  list.
2026-06-29 10:53:03 +08:00
修雨 b214c0a06c fix(sheet): wukong parity for range read/update
- accept scalar cells in range update
- flat values projection on read; null clears a cell
- add --hyperlinks flag to range update
2026-06-29 10:53:03 +08:00
修雨 d3087d170b feat(compat): native @file / --contents-file input for structured JSON flags
`dws report entry submit --contents-file <f>` (and `--contents -` stdin) silently
submitted `contents:[null]`. Root cause: the `--contents-file` flag had no
transform, so its value mapped to an unused param while `--contents` stayed empty.

- file_read_json transform + a build-time report hook resolve --contents-file /
  --contents - / @file natively in Go (priority: file > stdin > inline) and
  declare a contents / contents-file one-of group so a file-only invocation is
  no longer rejected at parse time.
- generalizes to @file / @- input for any structured JSON-array flag.
2026-06-29 10:53:03 +08:00
修雨 49637d982e feat(compat): hide phantom override commands from --help (tool-existence guard)
Override leaves whose backing MCP tool isn't actually deployed rendered in
`dws <svc> --help` but failed at invocation with "tool not found" (43 phantom
commands across 391 overrides; attendance declared 38, only 4 deployed).

BuildDynamicCommands now takes an existingTools oracle (CLI slug -> live tool
set from the tools/ cache). A leaf whose tool is missing from its resolved
server's set is marked Hidden; groups left childless collapse. Safety rails:
acts only when a server's tool set is KNOWN and non-empty (cold cache / overlay
/ plugin paths pass nil and no-op, never blanking the tree); serverOverride
leaves resolve against the target server; pipeline leaves are never hidden.

Adds scripts/dev/check-phantom-overrides.py as a publish-time gate, and
phantom_guard_test.go covering hide / cold-cache-keep / serverOverride / pipeline
/ empty-group-collapse.
2026-06-29 10:53:03 +08:00
修雨 4c5f1faeb1 feat: align open CLI with dws-wukong via cedar discovery version code (#509)
Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
2026-06-26 21:49:38 +08:00
修雨 5833e71751 ci(mirror): localize README for the Gitee mirror (#513)
The Gitee mirror force-pushes main verbatim, so Chinese users saw a
README whose top install commands point at raw.githubusercontent.com
(hard to reach in China) and a coverage badge that fails to render
(the relative .github/badges/coverage.svg can't be served by Gitee —
gitee raw returns a signed, expiring URL with content-type text/plain).

Add a Gitee-only post-process step: build a gitee-main branch on top of
origin/main and rewrite README.md / README_zh.md before pushing —
(1) raw.githubusercontent.com/<repo>/main -> gitee.com/<repo>/raw/main
(2) the coverage badge -> a shields.io static badge whose percentage is
read from the repo's coverage.svg and colored by threshold.

GitHub's README is untouched; only the Gitee copy is rewritten. The
branch is rebuilt from origin/main every run, so it stays a clean
single-commit delta and never drifts.
2026-06-26 16:55:04 +08:00
修雨 0e690fbe4e fix(install): define $LatestUrl in install.ps1 so version resolution works (#512)
Resolve-LatestVersion referenced $LatestUrl (lines 185/197) but the
variable was never defined, so on the default GitHub path both
Invoke-WebRequest calls failed with a null Uri. With
$ErrorActionPreference = "Stop" the script then hit Write-Err and
exit 1 — closing freshly-launched PowerShell windows instantly
(the reported "闪退"). Every user on the default `latest` path was
affected; the Bash installer was unaffected because it inlines the URL.

Define $LatestUrl = "https://github.com/$Repo/releases/latest", mirroring
the Bash installer. Verified end-to-end with pwsh 7.5: the script now
resolves the latest tag, downloads, checksum-verifies and installs.
2026-06-26 16:27:31 +08:00
Ariand修雨 f7e8106a72 docs(devapp): add image-upload recipe + "discovering commands" to dingtalk-dev skill (#508)
* docs(devapp): add image-upload recipe + "discovering commands" to dingtalk-dev skill

The dingtalk-dev skill could set an app/robot icon via --icon-media-id but
never documented where a mediaId comes from: the dev command set has no
upload command, so a mediaId must be fetched from DingTalk's OpenAPI. Agents
had to guess the flow. The per-resource refs also lacked a uniform pointer to
self-discover commands and params, so they leaned on memory instead of --help
/ schema.

recipes.md: new "上传图片拿 mediaId" recipe — credentials get -> gettoken ->
OpenAPI /media/upload (multipart field `media`, type=image) -> robot config /
app update --icon-media-id -> read back. Includes a curl example and notes the
token TTL (~7200s, rate-limited) and a square-icon hint.

references/*.md: append a Chinese "发现命令" block to each of the 10 product
refs (app, credentials, webapp, permission, member, security, robot, version,
event, connect). Each block shows that group's own `--help` plus
`dws schema dev.app.<group>.<method>` (connect uses `dws schema dev.connect`),
mirroring SKILL.md's MUST DO.

Verified end-to-end on a real app (unifiedAppId via dws dev): uploaded a PNG
through /media/upload, set the robot icon with the returned mediaId, and
`robot get` reflected the new iconMediaId with robotStatus=ONLINE. All 10
`--help` targets and the 9 `dws schema dev.app.*` paths + `dws schema
dev.connect` resolve.

* docs(changelog): note dingtalk-dev mediaId recipe + command discovery (#508)

---------

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-06-25 23:46:48 +08:00
修雨 87a9b5b9be chore: drop dead fork dev-app cruft (feat/dws-devapp) (#507)
Two leftovers referenced the decommissioned wxianfeng fork branch
`feat/dws-devapp`, both now obsolete after the dev-app work landed on main
(v1.0.42) and the installers were repointed to DingTalk-Real-AI (#505):

  - .github/workflows/auto-dev-release.yml — triggered only on push to
    feat/dws-devapp (a branch that does not exist on this repo, so it never
    fires). Its purpose — auto-publishing fork dev-preview releases for
    install-devapp.sh — is gone now that install-devapp.sh pulls stable
    releases from DingTalk-Real-AI.
  - docs/devapp-yulan-command-routing.md — a 2026-06-05 draft design doc
    pinned to the fork branch and the pre-rename `devapp` command tree,
    superseded by the shipped `dws dev` command set and the rewritten
    docs/devapp-agent-install-guide.md.

After this, the repo has zero `wxianfeng` / `feat/dws-devapp` references.
2026-06-25 21:49:10 +08:00
修雨 97678e6441 fix(devapp): drop the fork — repoint dev installer + docs to DingTalk-Real-AI (#505)
* fix(devapp): point dev installer at DingTalk-Real-AI, drop the fork

install-devapp.sh / .ps1 and the robot quickstart still pulled the dev
binary + dingtalk-dev skill from wxianfeng/dingtalk-workspace-cli's
feat/dws-devapp fork branch. The dev-app work has since landed on main and
shipped in stable v1.0.42 under DingTalk-Real-AI, so the fork dependency is
obsolete.

  - DEVAPP_REPO default: wxianfeng/... → DingTalk-Real-AI/...
  - Bootstrap URLs in headers + quickstart: fork feat/dws-devapp → main.
  - Drop "preview/prerelease" wording — releases are now stable; the
    newest-release resolution still works either way.
  - Quickstart China note now points at the standard install.sh Gitee
    mirror (which carries dws dev in v1.0.42); install-devapp.sh pulls its
    binary from github.com, so a gitee-raw script alone would not help China.

Verified: releases?per_page=1 on DingTalk-Real-AI resolves v1.0.42 and the
darwin/​skills assets are present.

Note: docs/devapp-agent-install-guide.md is separately stale (describes the
old source-build flow + the pre-rename `dws devapp` command) and needs its
own rewrite — left out of this change.

* docs(devapp): rewrite agent install guide for binary install + `dws dev`

The guide was stale on two axes:
  - It described the old source-build flow (clone the fork branch + go/make,
    env vars DEVAPP_REPO_URL / DEVAPP_BRANCH / DEVAPP_SOURCE_DIR), but
    install-devapp.sh now downloads a pre-built binary (curl + tar, no
    git/go/make) from DingTalk-Real-AI.
  - Every command used the pre-rename `dws devapp ...`; the command is now
    `dws dev app ...`.

Rewrite against the real `dws dev` tree (verified from the binary):
  - install: DingTalk-Real-AI binary installer + correct env (DEVAPP_REPO /
    DEVAPP_VERSION / DWS_INSTALL_DIR / DWS_NO_SKILLS) + a China Gitee note.
  - skill name corrected to `dingtalk-dev`.
  - commands: `dws dev app {list,get,create,update,enable,disable,delete,
    credentials,permission,member,robot,security,version,webapp,event}` with
    real flags (--confirm-name, --scope-values, --user-ids, --redirect-urls,
    --version-id/--confirmed-sensitive), async robot create via submit/result,
    version publish gated by check-approval.
2026-06-25 21:04:38 +08:00
修雨 67090ae09f docs: add China (Gitee) install for the standalone Skills installer (#504)
The China-mirror section documented the main install.sh and the npm
package, but not the standalone install-skills.sh — even though that
script already honours DWS_GITEE_REPO and auto-falls back to Gitee when
GitHub is unreachable. The Skills install section only showed the GitHub
URL, so China users (and docs curated from this README) had no China
entry point for skills.

Add a "Skills only (Gitee mirror)" item to both China-mirror sections and
a pointer next to the Skills install command, in README.md and README_zh.md.
2026-06-25 21:04:34 +08:00
修雨 6f042f9167 fix(release): Gitee mirror reads real attach ids + dedups duplicate assets (#502)
The verify-replace mirror listed attachments via /releases/{id}, whose
"assets" array omits the attach id. DELETE /attach_files/{id} was therefore
called with an empty id and silently no-op'd, so a stale asset was never
removed — instead a second (correct) copy was uploaded. Gitee then serves the
OLDER attachment by name, so the stale darwin binaries kept winning and failed
install.sh's checksums.txt verification on macOS (国内 install broken).

Fix:
  - List attachments via the dedicated /attach_files endpoint, which DOES
    return the numeric id needed for deletion.
  - Treat duplicates: collect every attach id carrying a given name; when >1,
    delete them all and upload exactly one fresh, correct file. count==1 still
    does the byte-identical skip / stale-replace; count==0 uploads new.

Self-heals the existing v1.0.42 darwin duplicates on the next mirror run.
2026-06-25 20:10:34 +08:00
meng93 78dd4aaa4b Merge pull request #501 from DingTalk-Real-AI/fix/gitee-mirror-verify-replace
fix(release): Gitee mirror verifies content + replaces stale assets
2026-06-25 17:07:23 +08:00
修雨 088a4d67ae fix(release): Gitee mirror verifies content and replaces stale assets
The v1.0.42 Gitee release served darwin-amd64/arm64 binaries that did NOT
match checksums.txt (the macOS binaries are ad-hoc signed and differed
between the GitHub release and the earlier mirror run), so install.sh's
checksum verification failed for China macOS users. The previous skip-if-name-
present logic could not repair this — it skipped the stale assets.

sync-to-gitee.sh now verifies by content: for each artifact it compares the
sha256 of the asset already on Gitee against the local file (downloaded from
the GitHub release), and

  • skips it when byte-identical,
  • deletes + re-uploads it when present but stale,
  • uploads it when missing,

bringing the Gitee release into byte-for-byte agreement with the GitHub
release that checksums.txt describes. Re-running the Sync-release-to-gitee
workflow now self-heals a mismatched mirror.

bash -n + sha256 helper validated locally.
2026-06-25 16:54:46 +08:00
修雨 81f5245c8a Merge pull request #499 from DingTalk-Real-AI/fix/gitee-release-sync-idempotent
fix(release): idempotent Gitee mirror + standalone repair workflow
2026-06-25 16:33:20 +08:00
修雨 c4946c3eaf fix(release): make Gitee mirror idempotent + add standalone repair workflow
The v1.0.42 Release job hit timeout-minutes: 30 mid-upload while mirroring
release assets to Gitee, so the Gitee release ended up missing
dws-windows-arm64.zip and checksums.txt. Root cause + fixes:

- sync-to-gitee.sh now skips assets already attached to the Gitee release,
  so a re-run only uploads what is missing (instead of re-uploading every
  artifact and creating duplicates). It no longer fails when everything is
  already present.
- New workflow sync-release-to-gitee.yml (workflow_dispatch, version input)
  mirrors a published GitHub release's assets to Gitee on its own — it
  downloads the assets from the GitHub release and runs the idempotent sync,
  without running GoReleaser or touching the GitHub release (no outage). Use
  it to repair an incomplete Gitee mirror.
- Bump the Release job timeout 30 -> 60 so a full Gitee upload has room.

bash -n + YAML validated.
2026-06-25 16:21:34 +08:00
修雨 a0b956a780 Merge pull request #496 from wxianfeng/feat/dws-devapp
feat(devapp+connect): dev app management, robot connect, opencode server sessions
2026-06-25 15:19:39 +08:00
修雨 5e4d974039 Merge pull request #23 from wxianfeng/release/changelog-1.0.42
docs(changelog): cut 1.0.42
2026-06-25 14:47:24 +08:00
修雨 bb641c2098 docs(changelog): cut 1.0.42
Promote the [Unreleased] section to 1.0.42 (2026-06-25) ahead of封包:
robot connect custom channel, /new vs /clear session commands, and the
opencode 30s timeout fix. A fresh empty [Unreleased] stays on top.
2026-06-25 14:39:20 +08:00
修雨 7e1e93478b Merge pull request #22 from wxianfeng/docs/readme-connect-section
docs(readme): add DingTalk bot connect section with /new /clear
2026-06-25 14:33:06 +08:00
修雨 101e7be98d docs(readme): add a DingTalk bot connect section with /new /clear
README.md / README_zh.md had no connect coverage at all. Add a short
"connect a robot to your local AI" section above Key Services: the one-line
`dws dev connect` usage, the in-chat /new vs /clear session commands (with
the per-channel real-session-op behaviour, opencode DELETE /session), and a
pointer to docs/robot-quickstart.md. Docs only.
2026-06-25 14:21:32 +08:00
修雨 9545c1dde5 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	.github/badges/coverage.svg
2026-06-25 14:12:58 +08:00
修雨 81367b2861 Merge pull request #21 from wxianfeng/docs/changelog-connect-fixes
docs(connect): changelog + quickstart for /new /clear and the 30s timeout fix
2026-06-25 14:03:31 +08:00
修雨 97bab33c71 Merge pull request #494 from shangguanxuan633-lab/codex/dws-auth-login-recommend
feat(tui): apply DingTalk auth console styling
2026-06-25 14:02:21 +08:00
修雨 ef6d65087f docs(connect): changelog + quickstart for /new /clear and the 30s timeout fix
Document this release's two connect changes that landed via #19 and #20:
- CHANGELOG [Unreleased]: Added entry for /new vs /clear using each
  channel's real session op (opencode DELETE /session); Fixed entry for
  the opencode 30s client-timeout that aborted long agent turns.
- robot-quickstart.md: a 会话指令 section explaining /new (open fresh,
  keep old) vs /clear (real per-channel wipe; opencode deletes the
  server session).

Docs only; no code change.
2026-06-25 13:59:41 +08:00
shangguanxuan.sgx 30a024e615 fix(auth): keep chmod batch grants gated 2026-06-25 12:16:31 +08:00
shangguanxuan.sgx 00e5ce7367 fix(auth): skip recommend tui when no scopes remain 2026-06-25 11:23:08 +08:00
shangguanxuan.sgx 760c40dd71 Merge upstream main 2026-06-25 11:10:25 +08:00
修雨 aaa2d7d3be Merge pull request #20 from wxianfeng/feat/connect-session-commands
feat(connect): /new vs /clear use each channel's real session op
2026-06-25 11:01:11 +08:00
修雨 fbfcd69c2b feat(connect): make /new vs /clear use each channel's real session op
Before, /new and /clear were identical: both just dropped the local
conversationId->sessionId mapping. The agent-side session was never
disposed (opencode sessions leaked), and the two commands had no real
difference beyond their ack text.

Align them to each channel's real capability:
- /new (resetSession): drop the local mapping only; the old agent session
  is left intact and stays resumable where the agent supports it.
- /clear (new sessionClearer): actively dispose the current session via the
  agent's real delete primitive. opencode implements it with
  DELETE /session/:id (idempotent on 404). Channels whose agent exposes no
  delete in the mode DWS drives it (Codex app-server, Qoder stream, Claude
  exec) fall back to resetSession, so /clear behaves like /new there.

Tests pin both directions: /clear issues exactly one DELETE and drops the
mapping; /new (resetSession) issues zero DELETEs so the old session stays
resumable. Doc updated to describe the per-channel behavior.

go build ./... ok; go vet ok; go test ./internal/helpers ok.
2026-06-25 10:43:21 +08:00
修雨 fab9e5be52 Merge remote-tracking branch 'upstream/main' into feat/dws-devapp
# Conflicts:
#	CHANGELOG.md
2026-06-24 22:32:25 +08:00
修雨 ad056a8d83 fix(config): revert DefaultMCPBaseURL to production (mcp.dingtalk.com)
The fork's dev branch had the pre-release URL (pre-mcp.dingtalk.com)
hardcoded as the default MCP base, which would leak prepub config into
any release build. Revert to the upstream production default.

🤖 Generated with [Qoder][https://qoder.com]
2026-06-24 22:31:09 +08:00
修雨 0de6e46ef4 docs: cut 1.0.41 changelog + backfill 1.0.40 #488 (#495)
- 1.0.41: install auto-fallback to Gitee (#492) + CI auto-push mirror (#493)
- 1.0.40: add the missing #488 (skills embedded in binary) entry
2026-06-24 22:26:54 +08:00
修雨 9a8157a4cd ci(mirror): auto-push code to Gitee via HTTPS token (no SSH key) (#493)
Rewrite mirror-to-gitee.yml to push main + tags to the Gitee mirror using
HTTPS + GITEE_TOKEN (reusing the existing secret) instead of hub-mirror-action
+ an SSH key. Gated on GITEE_TOKEN; needs GITEE_USER + GITEE_REPO secrets.
Keeps Gitee code/raw in sync automatically on every push to main + tags, so
no more manual git push after each release.
2026-06-24 22:26:47 +08:00
修雨 b8e915edfb feat(install): auto-fallback to Gitee when GitHub unreachable (#492)
Probe GitHub Releases on startup; if it is unreachable (typical in mainland
China), automatically switch to the Gitee mirror — so a plain
`curl … | sh` (or .ps1 / install-skills.sh) works everywhere with no
DWS_GITEE_REPO env var. Explicit DWS_GITEE_REPO still wins; DWS_NO_FALLBACK=1
disables the probe; local source-checkout installs skip it.
2026-06-24 22:26:40 +08:00
xianfeng wang 5e254b4745 Merge pull request #17 from wxianfeng/sync/upstream-main-2026-06-24
sync: merge upstream/main into feat/dws-devapp (1.0.35–1.0.40)
2026-06-24 22:19:42 +08:00
修雨 29a8a14760 Merge pull request #19 from wxianfeng/fix/opencode-message-timeout
fix(connect): opencode message turns use per-turn ctx, not a 30s client cap
2026-06-24 21:54:20 +08:00
shangguanxuan.sgx 00bef0a809 fix(auth): skip login scope tui when grants are complete 2026-06-24 21:53:18 +08:00
修雨 3f5b2fa8d3 fix(connect): let opencode message turns use the per-turn ctx, not a 30s client cap
The shared opencode http.Client had a hardcoded 30s Timeout used for every
request including POST /session/{id}/message. Long agent turns (e.g. a web
research report) take longer than 30s, so the client aborted mid-flight with
"context deadline exceeded (Client.Timeout exceeded while awaiting headers)",
even though the per-turn budget (DWS_AGENT_TIMEOUT_MS, default 300s) was far
larger. Retrying hit the same wall.

Drop the client-level deadline (rely on the per-request ctx that doJSON already
wires via http.NewRequestWithContext) and bound only the /global/health probe
with a short 10s timeout so startup detection stays snappy.

Tests: pin Timeout==0 on the default client, and assert a slow reply succeeds
within the turn budget but is cut when the turn ctx is shorter than the reply.
2026-06-24 21:39:40 +08:00
shangguanxuan.sgx b0e7b58e95 feat(tui): apply DingTalk auth console styling 2026-06-24 21:32:49 +08:00
修雨 fe46cd4dc2 Merge pull request #18 from wxianfeng/codex/qoder-stream-sessions
[codex] keep Qoder sessions on stream-json
2026-06-24 20:57:47 +08:00
quanming c993086d9d feat(connect): keep qoder sessions on stream-json 2026-06-24 20:36:26 +08:00
修雨 f522a9c2c2 Merge upstream/main into feat/dws-devapp
Sync with DingTalk-Real-AI/main (1.0.35–1.0.40):
- 1.0.35–1.0.39: chat @-mentions, sheet export, doc strip unsafe chars,
  PAT batch grants, devdoc RAG, agent_code detection, AI-tag opt-in
- 1.0.40 (#486): China mirror via Gitee + npmmirror
- (#488) embed skills/ into binary
- (#464) strip duplicate H1 on JSONML doc-create

# Conflicts:
#	.github/workflows/release.yml
#	CHANGELOG.md
#	README.md
#	README_zh.md
#	docs/command-index.md
#	internal/app/runner.go
#	internal/app/runner_test.go
#	internal/auth/channel.go
#	internal/helpers/devdoc.go
#	internal/helpers/devdoc_test.go
#	internal/pat/chmod.go
#	internal/pat/chmod_test.go
#	internal/pat/pat.go
#	internal/transport/client.go
#	scripts/install-skills.sh
#	scripts/install.ps1
#	scripts/install.sh
#	skills/mono/references/products/devdoc.md
#	skills/mono/references/products/simple.md
#	skills/multi/dingtalk-devdoc/references/devdoc.md
#	skills/multi/dingtalk-skill/references/skill.md
#	test/cli_compat/devdoc_test.go
#	test/cli_compat/helpers_test.go
#	test/skill_tests.md
#	test/skill_tests_results.md
#	test/unit/pat_host_owned_signal_test.go
2026-06-24 20:15:53 +08:00
修雨 bd370ed0e9 fix(doc): strip duplicate title H1 on the JSONML doc-create path (#464)
PR #448 stripped a leading H1 matching --name on the markdown path of
`dws doc create`, but the JSONML path (--content-format jsonml) was never
covered. Rich documents — tables, callouts, styled blocks — go through
create_document + update_document(jsonml=...), which writes the body
verbatim, so a leading h1 whose text equals the document name renders the
title twice (the "two headings" effect the doc platform shows because it
already renders --name as the page title).

Add stripLeadingDuplicateTitleJSONML: parse the marshaled JSONML body,
skip an optional ["root", {}, ...] wrapper, and drop the first node when
it is an h1 whose concatenated leaf text equals --name (trimmed,
case-insensitive). Any parse failure or non-match leaves the body
untouched, so a valid write is never blocked. A stderr note mirrors the
markdown path so agents learn the convention.
2026-06-24 18:34:16 +08:00
修雨 9fa76f8598 docs: cut 1.0.40 changelog + fix gitee mirror gate (#489)
- CHANGELOG: 1.0.40 (China mirror via Gitee + npmmirror, #486)
- mirror-to-gitee.yml: gate on GITEE_PRIVATE_KEY so it skips cleanly when the
  SSH key is unset (code mirror handled by Gitee-side pull-mirror) instead of
  failing the run.
2026-06-24 17:55:35 +08:00
修雨 b175acb48f feat(skill): embed skills/ into the binary, default skill setup to it (#488)
go:embed the skills/ tree (mono + multi) into the binary and make
`dws skill setup` install from the embedded bundle by default. Upgrading
the binary now refreshes the installed skill, instead of silently reusing
a stale copy probed from the current working directory. An explicit
--source / DWS_SKILL_SOURCE still overrides for development.

Replants the change from #441 onto main: #441 was stacked on the
abandoned feat/chat-bot-provisioning-openclaw base (PRs #407/#397 closed),
which is why its CI was red. The embedded-bundle test asserted a
connect.md doc that only existed on that dead base; retargeted it to a
reference doc that exists on main.
2026-06-24 17:55:23 +08:00
wxianfeng d89649f9bb event search first 2026-06-24 17:53:08 +08:00
Aemeathandquanming f218a05ead [codex] use opencode server sessions for OpenCode connect
Co-authored-by: quanming <quanming@code.alibaba-inc.com>
2026-06-24 17:18:58 +08:00
修雨 ed1cbd6f06 feat(install): China mirror via Gitee + npmmirror (#486)
国内安装加速:DWS_GITEE_REPO 开关从 Gitee 解析下载脚本/二进制/skill(默认仍 GitHub,向后兼容);release.yml 发版后 CI 自动镜像 release 附件到 Gitee;README 加国内安装说明。Approved-by: wxianfeng.
2026-06-24 17:14:40 +08:00
修雨 62aef1cc96 Merge pull request #15 from wxianfeng/codex/connect-appserver-qoder-memory
[codex] align connect memory for Codex and Qoder
2026-06-24 15:16:39 +08:00
quanming fc5e4d0d8d fix(connect): align codex and qoder sessions 2026-06-24 15:09:50 +08:00
xianfeng wang 34248fabf3 Merge pull request #14 from PeterGuy326/feat/connect-channel-memory
feat(connect): slash commands + all-channel session memory + interactive onboarding
2026-06-24 13:43:25 +08:00
修雨 430d20f2ee ci(release): auto-sync release artifacts to China OSS mirror
Add scripts/release/sync-to-oss.sh and wire it into release.yml. After
the GitHub release, artifacts (binaries, checksums, dws-skills.zip) plus
the install scripts are pushed to an OSS bucket in the
<base>/<version>/<file> layout the install.sh DWS_RELEASE_BASE switch
expects, with a latest.txt pointer.

Gated on OSS_* repo secrets: skips cleanly when unset, so forks without
the secrets keep releasing to GitHub only.
2026-06-24 11:43:06 +08:00
修雨 ada4acc395 feat(connect): interactive onboarding to provision or pick a robot app
When `dev connect` runs with no credentials (no --robot-client-id/secret, no
--unified-app-id) in a real terminal, it now guides the user instead of failing:

- Ask "new app or existing app?".
- Existing: read a unified-app-id (reuses the credentials-get path) or an
  explicit clientId/clientSecret pair.
- New: read the app name, robot display name and description, submit the async
  robot-create task, then poll for the result to obtain credentials. This has a
  real side effect (it provisions a real robot app).

connect_onboarding.go keeps the flow channel-agnostic and io-injected so the
whole decision tree is unit-tested with a mock runner and scripted stdin (no
network, no real provisioning). Non-interactive invocations — scripts, daemons,
pipes, and --dry-run — are unchanged: they still require an explicit credential
flag, so nothing that worked before starts prompting.

Tests cover: existing/unified, existing/raw-creds, new-app submit+poll (asserts
the submit_robot_create_task / query_robot_create_result calls and params),
invalid choice, and a missing required field (no side-effecting call).

Note: the real provisioning path shares the same unverified credential field
names as devAppFetchCredentials (clientId/appKey, clientSecret/appSecret — see
its TODO(verify)); the live create→credentials flow is to be verified on the
pre-prod gateway with the -dws-devapp prerelease before relying on it.
2026-06-24 11:41:49 +08:00
修雨 8bebd77dfa feat(install): add DWS_RELEASE_BASE/DWS_LATEST_URL mirror switch for China
Parameterize the hardcoded GitHub Releases domain in install.sh,
install.ps1 and install-skills.sh so domestic users can point asset
downloads (binary tarball, checksums, skills zip) and latest-version
resolution at a China-accessible mirror (e.g. OSS+CDN / dingtalk domain).
Defaults stay on GitHub — fully backward compatible.
2026-06-24 11:30:12 +08:00
修雨 b096fa06db feat(connect): give opencode per-conversation memory via captured sessions
opencode persists its own sessions but, unlike the Claude family, will not let
the caller mint the session id up front: `opencode run` creates a session on the
first turn and only reports its id in the `--format json` event stream, then
continues it with `--session <id>`. So the connector now CAPTURES the id from
the output (like codex's thread id) instead of minting a UUID:

- connect_opencode.go: a dedicated opencodeForwarder that runs
  `opencode run --pure --format json`, accumulates reply text from `text`
  events, captures the top-level sessionID, and on the first turn persists the
  convID->sessionID map to <config>/connect/<clientId>/opencode-sessions.json
  (best-effort, restored on startup). The next message — and a connector
  restart — replays it with `--session <id>`. Implements streamingForwarder and
  sessionResetter, so /new and /clear work and streaming cards stream.
- forwarderForChannel routes the opencode channel to it.
- connectAgentOptionsPayload reports opencode memory as
  "per-conversation-captured"; --agent-memory help now lists codex/opencode too.

Verified end to end against the real opencode CLI: turn 1 captures the session
id, turn 2 with --session recalls a fact from turn 1. Unit tests cover capture +
resume (stub bin), store persistence across restart, and reset.

gemini also exposes --session-id/--resume but could not be verified end to end
here (no GEMINI_API_KEY) and its --resume id semantics are unconfirmed, so it is
left for a follow-up rather than wired blind. qoder's qodercli still has only
--resume (no addressable id) and cannot do per-conversation memory.
2026-06-24 10:57:25 +08:00
修雨 107f3eaf6e feat(connect): add /new /clear slash commands and persist codex session context
Two connector improvements for the linked-bot chat experience, modelled on
codex / OpenClaw connect:

1. Built-in slash commands. A user can type /new (aliases /start, /reset) to
   open a fresh session or /clear to wipe the current one, instead of the
   message being forwarded to the agent. The command set is fixed (never
   dynamically extended) so behaviour stays predictable. Matching requires the
   whole message to be the bare command, so a normal question that merely starts
   with a slash is forwarded untouched. Costs no agent turn / tokens.

   - connect_command.go: channel-agnostic parser + action table (+ tests).
   - sessionResetter optional interface; execForwarder (Claude family) and the
     codex app-server forwarder both implement it. The main loop intercepts a
     command right after the owner-decision interceptor and acks via webhook.

2. Persist codex per-conversation context. codexThreadSessions previously kept
   the convID->threadID map in memory only, so a codex connector restart lost
   every conversation's context — unlike the Claude-family convSessions store
   which already persisted. The thread identity is now the authoritative,
   mutex-guarded map persisted to <config>/connect/<clientId>/codex-threads.json
   (best-effort, atomic write), restored on startup. An empty clientId keeps it
   in memory only, preserving the original behaviour. This also removes a data
   race on threadID by moving it off the per-conversation state into the
   sessions store.
2026-06-24 10:30:04 +08:00
shangguanxuan.sgx df0c0f7545 revert(auth): restore pat authorization link output 2026-06-24 10:26:05 +08:00
qinzeandClaude Opus 4.8 91e67e2e45 docs(dev): event.md 事件码改用 event list 查询
去掉"以开放平台文档为准/dev doc search",改成"可订阅事件码通过 event list 查询
(返回 events[] 列出 eventCode/eventName/subscribed)"——event list 本就列出所有可订阅事件,更准。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:58:40 +08:00
shangguanxuan.sgx 75468dca1e fix(auth): hide raw pat url after browser open 2026-06-24 09:41:03 +08:00
shangguanxuan.sgx ef5c05a2e6 fix(auth): avoid duplicate pat authorization link output 2026-06-24 00:04:32 +08:00
shangguanxuan.sgx 1ac8636418 fix(auth): restore login authorization selectors 2026-06-23 23:09:11 +08:00
shangguanxuan.sgx 86086437d8 fix(auth): default login to recommended cli authorization 2026-06-23 21:57:17 +08:00
wxianfeng d432029d84 Merge branch 'feat/dws-devapp' of github.com:wxianfeng/dingtalk-workspace-cli into feat/dws-devapp 2026-06-23 19:45:19 +08:00
wxianfeng 029bfdd2ed version publish approval 2026-06-23 19:44:36 +08:00
修雨 8c0551f359 fix(install-devapp): handle GitHub API rate limit when resolving version
Unauthenticated curl is limited to 60 req/h, and once exhausted the API
returns 403. The previous `curl -fsSL ... | grep tag_name` swallowed the
403 silently, leaving DEVAPP_VERSION empty and surfacing a misleading
"No release found" error even though the fork has plenty of releases.

- Prefer `gh api` when available (authenticated, 5 000 req/h).
- Fall back to curl while capturing the HTTP status; on 403/429 fail
  with an actionable message instead of pretending no release exists.
2026-06-23 16:55:25 +08:00
xianfeng wang b839ee664e Merge pull request #12 from wxianfeng/fix/opencode-connect-pure
fix(connect): run opencode with --pure to neutralise operator plugins
2026-06-23 15:43:57 +08:00
修雨 c16c4aa59a fix(connect): run opencode with --pure to neutralise operator plugins
The opencode channel forwarded to `opencode run` without isolating the
operator's interactive environment. claudecode already neutralises via
`--setting-sources project --strict-mcp-config`; opencode had no equivalent.

When the operator runs a plugin suite that swaps the global default agent at
runtime (e.g. OhMyOpenCode's rotating Greek-codename agents), headless
`opencode run` crashes resolving a default agent that no longer exists
("default agent ... not found"), and the crash trace is sent back to DingTalk
as the bot reply. Verified by reproducing in a clean /tmp dir: plain
`opencode run` crashes, `opencode run --pure` replies normally.

Add --pure to the opencode argvTail so the bot answers in a clean,
plugin-free environment, consistent with the claudecode channel.
2026-06-23 15:07:19 +08:00
修雨 aecc0bc588 Merge pull request #11 from PeterGuy326/fix/mono-skill-dev-routing
fix(skill/mono): add dev product routing, prevent robot-creation mis-routing to chat
2026-06-23 14:06:18 +08:00
wxianfeng cfad3bbf4a name 2026-06-23 13:57:21 +08:00
修雨 40736e2ae1 fix(skill/mono): add dev product routing to prevent robot-creation mis-routing to chat
Without a dev entry in the product table and intent tree, agents reading the
mono skill routed 'create robot' / 'connect' requests to dws chat (because
'机器人' appeared in the chat product description), then landed on
dws chat bot search — wrong path.

Changes:
- skills/mono/SKILL.md: add dev row to product table; annotate chat row to
  scope it to IM-only; add dev intent-tree rule covering 创建机器人/建联/
  接入 agent/opencode/…; prepend dev-vs-chat disambiguation rule
- skills/mono/references/products/dev.md: new file — full command reference
  for dws dev app (lifecycle/robot build-号/credentials/permission/event/
  version) and dws dev connect (建联 flags, channel list, dry-run cli check,
  codex gotcha), sourced from dingtalk-dev multi-skill references
2026-06-23 13:53:53 +08:00
xianfeng wang 9aeb60da37 Merge pull request #10 from PeterGuy326/release/dws-devapp
feat(connect+devapp): 数字分身建联整套(已合 dev 重构)
2026-06-23 11:00:00 +08:00
修雨 d3fcd814f3 docs(quickstart): 安装改用勤泽的一键脚本;移除对 wxianfeng 无效的 release step
- quickstart 第一步从手动 curl 直链改为引用 scripts/install-devapp.sh /
  install-devapp.ps1。直链走 /releases/latest 在 wxianfeng 会 404(它只发
  -dev 预览版,无非 prerelease release);一键脚本走 GitHub API 取最新预览版,
  现在即可装到 v2.0.0.dev,且和团队安装入口统一
- 移除我此前加的 'Mark dws-devapp release as latest' step:其触发条件
  contains('-dws-devapp') 在 wxianfeng 永不成立(这边发 -dev tag),是死代码;
  改用一键脚本后也不再依赖 /releases/latest。release.yml 现与 base 完全一致
2026-06-23 10:45:15 +08:00
修雨 5ed69744cc Merge wxianfeng/feat/dws-devapp into release/dws-devapp
解决 devapp→dev 重构与 connect/数字分身工作的合并冲突,方向:全面对齐
wxianfeng 的 dev 命名空间,保留我方两处实质内容(动态端点 + connect 功能)。

冲突解决要点:
- release.yml:取 wxianfeng 更干净的 npm 守卫(repository_owner + 注释);
  保留我新增的「-dws-devapp 翻成非 prerelease+latest」步骤
- devapp.go / devapp_test.go / 路由文档:取 wxianfeng(新 MCP 工具名
  *_dev_app_*/extension、新增 event 工具、删 robot create、config 合 upsert)
- 端点:保留我方动态 devappMCPEndpoint()(网关 base 推导、生产默认),
  否则会把「建号对所有人打预发」的 bug 倒灌回来;同步修 helper_tool_fetcher.go
  与 direct_runtime_test.go 改用该函数 + wxianfeng 工具名 list_dev_app
- devapp_connect.go:connect 命令对齐 dev connect 命名,保留我方 --agent-cmd
  自研渠道示例(#37),建号引用修正为真实存在的 robot submit
- skills:跟随 wxianfeng 删除旧 dingtalk-devapp(已被重写的 dingtalk-dev 覆盖)
- robot-quickstart.md:命令全改 dws dev(建号两步 submit+result、dev connect),
  下载源改 wxianfeng,去掉写死版本号用 releases/latest

验证:go build ./... 通过;go vet 干净;internal/app、internal/helpers 单测全过
2026-06-23 10:21:29 +08:00
修雨 e5c9c91342 fix(release+docs): 让 -dws-devapp 可被'装最新',文档去掉写死版本号
问题:-dws-devapp 全系列被 goreleaser 标成 prerelease(semver 后缀触发),
GitHub 的 /releases/latest 跳过 prerelease,导致:
- 文档写死的 v1.0.53 每发一版就过期(现已落后到 v1.0.60)
- releases/latest/download 与 install.sh 的 latest 解析对 devapp 全失效

修复:
- release.yml 新增一步,仅当 tag 含 -dws-devapp 时把该 release 翻成
  非 prerelease 并标记 latest;goreleaser 仍保持 prerelease: auto,
  不影响 -dev.x 等其他预发版的语义
- robot-quickstart.md 三处下载/链接改用 releases/latest,去掉写死版本号,
  提示语软化为'能打印版本即成功'
2026-06-23 09:53:29 +08:00
qinzeandClaude Opus 4.8 0e59fedbbb ci: dev 分支 push 自动出预览 release(测试通过才发)
- 新增 auto-dev-release.yml: push feat/dws-devapp → 跑 build+test →
  通过则自增 dev 版本号、打 tag、自包含发 release(不靠 tag 触发 release.yml,
  绕开 GITHUB_TOKEN 不触发的限制); 不含 npm 步骤天然绿; concurrency 串行防撞号
- release.yml: npm publish 加 owner 条件, fork 跳过避免红叉

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:36:42 +08:00
修雨 c39378864d fix(release): goreleaser owner 用 GITHUB_REPOSITORY_OWNER 而非写死 fork
对齐 wxianfeng/feat/dws-devapp 已有写法:官方 CI 发到官方、fork CI 发到
fork,两边都对,避免合并时把动态 owner 顶回写死 PeterGuy326。
2026-06-23 09:32:52 +08:00
qinze 7cb33e970c Merge branch 'feat/dws-devapp' of https://github.com/wxianfeng/dingtalk-workspace-cli into feat/dws-devapp
# Conflicts:
#	skills/multi/dingtalk-dev/references/event.md
2026-06-23 09:19:27 +08:00
qinzeandClaude Opus 4.8 567cf163f1 docs(dev): event.md 澄清 connect/event 在 Stream 上的差异 + Stream 建立指向官方文档
- 加「connect 与事件订阅的关系」: connect 收机器人消息、event 只是配置订阅,
  dws 当前不消费事件; 两者唯一关联是先 connect 让长连在线 subscribe 才成功
- 「Stream 长连怎么建」改为命令视角 + 指向官方 SDK 文档(事件 RegisterAllEventHandler
  vs 机器人 RegisterChatBotCallbackRouter 别混), 不固化易过时的 SDK 代码

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 09:18:37 +08:00
wxianfeng d9ae15f9a6 Merge branch 'feat/dws-devapp' of github.com:wxianfeng/dingtalk-workspace-cli into feat/dws-devapp 2026-06-22 20:46:12 +08:00
wxianfeng 922745f318 event search 2026-06-22 20:45:14 +08:00
qinzeandClaude Opus 4.8 58dced0c8a feat(install): dev 安装支持 Windows(sh 加 Windows 分支 + 新增 ps1)
- install-devapp.sh: detect_os 加 MINGW/MSYS/CYGWIN, 按平台选 .zip/.tar.gz
  和 dws/dws.exe, 覆盖 mac/linux + Git Bash/WSL 下的 Windows
- 新增 install-devapp.ps1: 原生 Windows 用 irm|iex 一键装, 自包含下
  dev release 的 dws.exe + dingtalk-dev skill(含 opencode 目录)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:54:49 +08:00
qinzeandClaude Opus 4.8 3427b65da6 feat(install): install-devapp.sh skill 目录增加 opencode
opencode 全局 skill 目录是 ~/.config/opencode/skills(在 .config 下,
与其它 agent 的 ~/.xxx 不同),加入安装列表; gate 检查 ~/.config/opencode 存在才装。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:46:40 +08:00
qinzeandClaude Opus 4.8 9d38a3be54 fix(release): goreleaser owner 用 GITHUB_REPOSITORY_OWNER
写死 DingTalk-Real-AI 导致 fork CI 想往官方仓库发 release 被 403 拒绝。
改用 CI 自带的 GITHUB_REPOSITORY_OWNER, fork CI 发到 fork、官方 CI 发到官方。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:31:24 +08:00
qinzeandClaude Opus 4.8 1cda263e0e feat(install): install-devapp.sh 改为自包含下二进制安装(cli+skill)
- 从 fork 的 dev release 下预编译二进制 + dws-skills.zip, 只需 curl+tar,
  不再 clone 源码编译(去掉 go/make/git 依赖)
- 装 dingtalk-dev skill(不改名), 命令名对齐 dws dev
- 版本默认取 fork 最新 release(含 prerelease)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:24:56 +08:00
qinzeandClaude Opus 4.8 5d5884a0d7 fix(dev): 校准 dingtalk-dev skill 与真机一致 + 清理重复安装脚本
- skill 订正(以真机/schema 为准): app appStatus 字符串/permission 状态字段与
  apiPreview/credentials 去掉不存在的 showSecret/webapp 结构化空态/connect 去过时
  警告并补 flag/robot 去掉错误的 success 字段; version 用远端结构化审批字段
- 删除重复的 install-dev.sh, 保留 install-devapp.sh 并去掉会误拦用户的 go/make 检查

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:07:14 +08:00
玉澜 4a26f1ebd2 Merge remote-tracking branch 'origin/feat/dws-devapp' into feat/dws-devapp 2026-06-22 17:06:47 +08:00
玉澜 b65be61599 fix: limit devapp installer skills 2026-06-22 16:18:58 +08:00
玉澜 e193ba97f4 feat: add devapp install script 2026-06-22 16:08:51 +08:00
wxianfeng 787a40ffcb fix cr problem 2026-06-18 22:54:24 +08:00
quanming f69d9ed281 fix: align devapp member user ids 2026-06-18 19:37:17 +08:00
wxianfeng f2b2de89f4 fix cr problem 2026-06-18 18:34:12 +08:00
shangguanxuan.sgx 7915a5a4e1 docs: refresh batch auth release evidence 2026-06-18 17:18:46 +08:00
修雨 91f44a1efd docs: cut 1.0.39 changelog (#475 wukong-leak fix + #477 ai-tag opt-in) (#478) 2026-06-18 14:10:07 +08:00
shangguanxuan.sgx 6067906c55 fix(devdoc): fallback empty rag article search 2026-06-18 13:08:34 +08:00
shangguanxuan.sgx 493ca36e08 fix(pat): carry agentCode in batch auth args 2026-06-18 13:08:15 +08:00
修雨 1a7ba01e36 feat(chat): make AI-sent badge opt-in via --ai-tag (default off) (#477)
Previously every user-identity send/reply attached clawType=edition.ClawType()
unconditionally, so the IM server rendered an AI-sent badge ("通过AI发送" on the
open edition) under every message — surprising users by branding all their sends.

Make it opt-in: by default no clawType is attached (no badge). Passing --ai-tag
on 'chat message send' / 'chat message reply' attaches edition.ClawType() so the
badge shows (open=openClaw -> 通过AI发送, wukong overlay -> 悟空AI发送). Bot and
webhook sends remain untouched.

Follow-up to #475 (which fixed the hardcoded wukong leak, #474).
2026-06-18 12:22:19 +08:00
修雨 6310dcc39e feat(chat): tag user-identity message sends with edition clawType (#475)
Attach the clawType tool argument to every user-identity send path
(send_personal_message text/rich-media/reply and
send_direct_message_as_user) so the IM server can render the
"Send from AI" indicator on delivered messages. The value comes from a
new edition hook (Hooks.ClawTypeValue, exposed via edition.ClawType())
that falls back to DefaultOSSClawType ("openClaw"); overlays such as
wukong set their own identity to get their branded indicator.

Also fixes the reply command, which hardcoded clawType="wukong" and
made open-source replies carry the Wukong AI identity.

Bot sends intentionally stay untouched: they already render as bot
messages and must not carry the user-identity claw tag.
2026-06-18 10:53:52 +08:00
qinze 518b1cb631 Merge remote-tracking branch 'origin/feat/dws-devapp' into feat/dws-devapp
# Conflicts:
#	skills/multi/dingtalk-dev/references/version.md
2026-06-18 10:48:58 +08:00
玉澜 f29655e7e4 fix(devapp): let server assign app version 2026-06-18 10:32:52 +08:00
qinzeandClaude Opus 4.8 cc8a726b0a docs(dev): dingtalk-dev references 二次精简
- permission/robot/version 删 bash 命令示例堆叠(schema/--help 已覆盖),
  保留状态机/枚举语义/链路/gotcha
- 去掉所有 reference 的"概念锚点"自造词标签,保留指向概念地图的引语
- SKILL.md 生效模型去"(最重要)";connect 去加粗

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 09:09:04 +08:00
shangguanxuan.sgx f0552dd20e docs: record latest auth login prepub evidence 2026-06-18 02:53:00 +08:00
shangguanxuan.sgx a040b57be7 fix: stop auth login post-approval tui retry 2026-06-18 01:44:31 +08:00
shangguanxuan.sgx a2e8700beb docs: record caller-only recommend fallback 2026-06-18 00:48:01 +08:00
shangguanxuan.sgx 98804801c9 docs: record recommend empty-plan recheck 2026-06-17 23:17:40 +08:00
shangguanxuan.sgx 6946dd1e35 docs: record empty plan guard verification 2026-06-17 22:21:38 +08:00
shangguanxuan.sgx d09fca4b9b fix: guard empty login recommend all-scope plan 2026-06-17 22:16:58 +08:00
qinzeandClaude Opus 4.8 70d66daa5f feat(dev): schema 覆盖 dev doc + dingtalk-dev skill 精简
- dws schema 支持 dev.doc.search:fetcher 按 source 取(op-app 钉死 pre/
  devdoc 走运行时解析),给 doc search 叶子加 mcp-tool/mcp-source 注解
- dev doc search flag 对齐 MCP 参数名(--keyword/--size 为主,旧名隐藏兼容)
- dingtalk-dev SKILL.md:顶部加 MUST DO(每次先 --help/schema 查),核心规则
  瘦身、去重、去加粗/箭头符号
- references 精简:删参数表/flag 列表(schema/--help 已实时提供),只留状态机/
  覆盖语义/链路/gotcha;event 补"订阅前需先建联"业务规则;新增 recipes.md
  端到端链路;典型任务收成指针

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 21:56:11 +08:00
shangguanxuan.sgx 1926bd17b9 docs: update latest bff prepub instance 2026-06-17 21:48:12 +08:00
shangguanxuan.sgx 6a392e611c docs: record prepub deployment recheck 2026-06-17 21:25:45 +08:00
shangguanxuan.sgx 710844382e docs: record bff selected scopes hardening 2026-06-17 20:34:49 +08:00
shangguanxuan.sgx b1a88106e5 docs: record current cli recommend verification 2026-06-17 19:49:50 +08:00
shangguanxuan.sgx dcf132a7e3 fix: discover products before all-scope plan 2026-06-17 19:21:54 +08:00
shangguanxuan.sgx d2fd7e00b5 fix: skip tui for recommend login 2026-06-17 18:47:51 +08:00
shangguanxuan.sgx 7720e3ec9b docs: clarify post-login recommend tui 2026-06-17 17:56:36 +08:00
shangguanxuan.sgx 537719c677 fix: run recommend tui after login 2026-06-17 17:51:24 +08:00
qinzeandClaude Opus 4.8 51db546686 Merge origin/feat/dws-devapp into feat/dws-dev
合并远端团队的 MCP 对齐与 CR 修复,与本地 dev 命令重构 + schema 实时拉取 + flag 对齐 MCP 参数名集成。

冲突解决要点:
- 工具名常量:两边已收敛到同一组 op-app 真实工具名,取 dev 子树(ours)标识
- 保留 schema 能力:32 个 annotateDevAppTool 注解 + flag=kebab(MCP参数名)不变
- 吸收远端 CR 修复:normalizeDevAppServiceResult 解包 ServiceResult、
  remove_dev_app_permissions 结果扁平化、生命周期工具状态标记
- 旧 dingtalk-devapp 技能目录保持删除(已重命名为 dingtalk-dev)
- devdoc article search 统一 --page-size/pageSize,--size 留兼容别名

构建/vet/internal 测试全绿(28 包,0 失败)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 17:01:50 +08:00
shangguanxuan.sgx e9d50a659f docs: correct recommend tui chain 2026-06-17 16:54:17 +08:00
shangguanxuan.sgx 2264743e78 fix: model recommend login tui chain 2026-06-17 16:47:16 +08:00
qinzeandClaude Opus 4.8 6244222adf feat(dev): schema 实时拉取 MCP + flag 全面对齐 MCP 参数名
- 新增 dws schema dev.* 实时从 op-app tools/list 拉取参数(gws 扁平格式),
  description/type/required/default 全部 verbatim 来自服务端,零本地合成
- flag 全部改为 kebab(MCP 参数名),去掉别名,schema 展示与 CLI flag 完全一致,
  修掉 schema 显示假参数名的问题
- CLI 同步新 MCP 契约(工具名复数化、参数名 rename)
- dingtalk-dev skill 文档同步新 flag 名

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:43:19 +08:00
shangguanxuan.sgx a31a29e0ab docs: record recommend yes gate 2026-06-17 16:02:25 +08:00
shangguanxuan.sgx 3ec35e30e8 fix: respect yes for recommend login grants 2026-06-17 15:50:42 +08:00
shangguanxuan.sgx da08e84e80 docs: record mgdone tui evidence 2026-06-17 15:13:26 +08:00
shangguanxuan.sgx 5e1983b3df fix: align recommend tui interaction 2026-06-17 15:06:14 +08:00
wxianfeng 8984f7b8be fix cr problem 2026-06-17 12:13:28 +08:00
wxianfeng dd08db54d8 Merge branch 'feat/dws-devapp' of github.com:wxianfeng/dingtalk-workspace-cli into feat/dws-devapp 2026-06-17 10:03:55 +08:00
wxianfeng 591609fdee fix cr problem 2026-06-17 10:03:04 +08:00
quanming 6447bdd45f fix: align devapp member security app id flag 2026-06-17 09:53:31 +08:00
玉澜 2610c8ce87 Merge branch 'feat/dws-devapp' of https://github.com/wxianfeng/dingtalk-workspace-cli into feat/dws-devapp 2026-06-17 03:32:19 +08:00
玉澜 0e6cb46f58 fix: align devapp mcp compatibility 2026-06-17 03:29:46 +08:00
shangguanxuan.sgx a613728fbd docs: record recommend tui human flow 2026-06-17 01:03:04 +08:00
shangguanxuan.sgx 70b9f39715 fix: use human recommend flow by default 2026-06-17 00:58:27 +08:00
shangguanxuan.sgx d29aebeecd docs: record current batch auth gate status 2026-06-17 00:44:58 +08:00
wxianfeng 844059a77b fix cr problem 2026-06-17 00:43:19 +08:00
shangguanxuan.sgx 9ee8ea5524 fix: show recommend selector by default 2026-06-17 00:34:23 +08:00
shangguanxuan.sgx 42c71c9c54 docs: add frontend cdn verification 2026-06-17 00:08:51 +08:00
shangguanxuan.sgx 690d0788e3 docs: refresh batch auth release evidence 2026-06-17 00:05:44 +08:00
shangguanxuan.sgx 5a7c0748e3 feat: add recommend login product TUI 2026-06-16 23:19:24 +08:00
wxianfeng 410949cdf3 fix cr problem 2026-06-16 21:02:38 +08:00
修雨 f1a68f2424 docs: cut 1.0.38 changelog (#469) 2026-06-16 20:20:20 +08:00
patrickmen 497e4f87d8 feat: support to disable auto open browser (#365) 2026-06-16 20:08:29 +08:00
Jackjin a8d009aec8 fix(upgrade): honor --dry-run as preview-only instead of silently upgrading (#416)
dws upgrade registered no --dry-run flag and never read the global one,
so --dry-run fell through to runUpgrade and performed a real, irreversible
upgrade (download + replace binary). This contradicts the flag's documented
contract (预览操作内容,不实际执行).

Resolve the target release and platform asset (so a missing build / 'already
latest' is still reported), then render the planned 1-5 steps and return
before any side effect: no backup, no download, no replace. Advertise
--dry-run in the command examples.

Fixes #364
2026-06-16 20:08:25 +08:00
thisred 1f413fa322 fix: pipeline PollUntil case-insensitive comparison to fix sheet export hang (#462) 2026-06-16 20:08:17 +08:00
玉澜 0197dbc81a fix: align devapp DTO field names 2026-06-16 19:18:27 +08:00
shangguanxuan.sgx b85353e042 fix: emit single PAT authorization URL 2026-06-16 18:07:56 +08:00
修雨 ece91bfa3c feat(agent): accurate agent_code detection + per-channel agentId for stats (#467)
Tag each MCP request with which agent host is driving dws (agent_code) and a
per-(machine × agent_code) instance id, so usage can be sliced by channel and
instance in the data warehouse. Root cause it fixes: agent_code was only sent
when the host injected DINGTALK_DWS_AGENTCODE (~99.98% empty), so the gateway
logged none.

Detection ladder (every signature observed on a real host / official docs, not
guessed; unknown -> custom):
  T0 explicit DINGTALK_DWS_AGENTCODE
  T1 verified env signatures: claudecode (CLAUDECODE), codex (CODEX_SANDBOX),
     openclaw (OPENCLAW_BUNDLE_ROOT), hermes (HERMES_HOME)
  T2 VSCODE_BRAND value (covers the whole VS Code fork family)
  T3 macOS __CFBundleIdentifier map (qoder/cursor/vscode/workbuddy)
  T4 custom fallback

identity.json v2 (machineId + per-agent_code agents map), deterministic
dwsa_<base62> derivation, transparent v1 migration. Backward-compatible wiring:
x-dws-agent-id stays machine-level; new x-dws-agent-instance-id carries the
per-channel id; X-Cli-Version emitted so old/new clients are distinguishable.

Trust boundary (docs/agent-code.md): agent_code and the ids are self-reported
and spoofable — fit for statistics ONLY, never for auth/limit/billing.

Includes unit tests for every tier and the integration doc.
2026-06-16 17:55:29 +08:00
qinzeandClaude Opus 4.8 f740955423 chore(dev): skill 测试基建适配 dev 命令集
run_skill_tests / skill_e2e / skill_static / skill_tests 更新到 dev 技能。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:12:05 +08:00
qinzeandClaude Opus 4.8 166f665708 chore(dev): devapp→dev 重命名收尾(docs/mono/install/README/gitignore)
删除旧 devapp docs;mono 技能与 intent-guide 去 devapp;install 脚本改名
install-dev.sh;README 更新;.gitignore 放行 skill credentials.md 文档。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:11:50 +08:00
qinzeandClaude Opus 4.8 55f5e0c3d0 chore(dev): 删除旧 skills/multi/dingtalk-devapp
multi 模式旧 devapp 技能整组下线,已被 dingtalk-dev 替代。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:11:32 +08:00
qinzeandClaude Opus 4.8 f58b426381 chore(dev): 删除旧 skills/dingtalk-devapp(root 模式)
devapp 旧技能整组下线,已被 multi/dingtalk-dev 替代。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:11:32 +08:00
qinzeandClaude Opus 4.8 2640338803 docs(dev): dingtalk-dev skill reference (2/2) + dev 功能测试
webapp 产品文档;dev 命令集功能测试用例与报告(测试产物 results.jsonl 不入库)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:11:21 +08:00
qinzeandClaude Opus 4.8 dbcbb1de85 docs(dev): dingtalk-dev skill 概念结构 + 产品 reference (1/2)
SKILL.md 重构为「概念地图 + 核心规则/通用出参约定 + 典型任务 + 产品索引」;
reference 改为一命令组一文件;不在 agent 文档暴露内部 MCP tool 名。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:11:09 +08:00
qinzeandClaude Opus 4.8 63b6112fa2 test(dev): dev 命令集测试 + 建联/端点路由适配
helpers 单测、connect/card/stream 测试,app runner 与 direct_runtime devapp
端点路由。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:09:18 +08:00
qinzeandClaude Opus 4.8 eaeb9ac05d feat(dev): 开放平台 dev 命令集核心实现
dev 伞形命令(app/connect/doc)、应用全生命周期、权限/成员/安全/机器人/
版本/事件、cursor 透传分页、pretty 状态标注、connect 建联。MCP 工具名对齐
服务端实际注册名。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:09:06 +08:00
shangguanxuan.sgx e82ccd3496 docs: refresh batch auth verification state 2026-06-16 16:34:10 +08:00
修雨 b4c9db8807 feat(connect): custom channel for self-built/unsupported AI (issue #37)
Add a generic 'custom' channel plus an --agent-cmd flag so 'dws devapp
robot connect' can forward to ANY headless AI CLI (question appended as
the trailing arg, stdout used as the reply). Onboards tools that aren't
built-in (e.g. LobsterAI) or self-built agents with no code changes:
--agent-cmd forces channel=custom unless --channel is set explicitly,
and auto-detection falls back to custom when DWS_AGENT_CMD is present.

Also (issue #39): print a one-time hint at connect start when neither a
work dir nor a knowledge source is set, pointing at --agent-workdir /
--knowledge-* / --agent-model so the bot can match terminal answer
quality. Quickstart gains matching FAQ entries plus a note that step 3
(robot connect) produces no approval ticket (issue #19).
2026-06-16 15:04:04 +08:00
shangguanxuan.sgx aa76f01015 docs: record PAT app dev API build fix 2026-06-16 15:03:40 +08:00
shangguanxuan.sgx 931d7e59ef docs: refresh PAT prepub dependency status 2026-06-16 14:57:58 +08:00
shangguanxuan.sgx db2043c82b docs: record batch auth prepub status 2026-06-16 14:45:10 +08:00
wxianfeng c2f3653ec4 get_extension_robot_config 2026-06-16 14:40:17 +08:00
shangguanxuan.sgx 11dfd4ccf2 feat: add dws recommended batch auth flow 2026-06-16 14:09:15 +08:00
wxianfeng ef60d99b26 get_robot_config 2026-06-16 12:12:19 +08:00
wxianfeng 1b6e197426 fix conflict 2026-06-16 11:04:12 +08:00
wxianfeng 41b743de77 test 2026-06-16 11:02:42 +08:00
wxianfeng caf672699f tool name rename 2026-06-16 11:01:33 +08:00
修雨 ff33114b2c feat(doc): strip server-rejected unsafe chars on markdown write path (#465)
The doc write boundary only stripped a fixed dangerous-Unicode set, and only
on the JSONML path. C0 control characters (except tab/newline), DEL (0x7F),
and a few zero-width / line-separator codepoints still reached the server,
where RejectControlChars rejects them — so doc create/update failed on content
that pasted in such characters (common with LLM-generated or copy-pasted text).

- Rename stripDocDangerousUnicode -> stripDocInputUnsafe and extend it to drop
  C0 controls (except \t and \n) and DEL, matching apiclient.rejectDangerousChars.
- Add U+200D, U+2028, U+2029 to the dangerous-Unicode set so it covers the
  full server-rejected range.
- Apply the strip on the markdown write path (doc create/update) and the JSONML
  node path, not just the JSONML body.
- Add unit tests for stripDocInputUnsafe.

Ported from dws-wukong (feat: 增加输入安全字符过滤功能).
2026-06-16 10:56:53 +08:00
玉澜 67ac777657 Merge remote-tracking branch 'origin/feat/dws-devapp' into feat/dws-devapp 2026-06-16 10:13:57 +08:00
玉澜 c9ba0373c2 fix: align devapp cli tool mappings 2026-06-16 10:10:43 +08:00
quanming 4a19530fd0 fix: align devapp MCP tool names 2026-06-16 09:57:11 +08:00
修雨 7a28d97739 Merge pull request #38 from PeterGuy326/fix/issue-35-robot-create-async
fix(devapp): robot create routes to async submit/poll (issue #35)
2026-06-16 09:38:50 +08:00
修雨 d63f7e5836 fix(devapp): robot create routes to async submit/poll (issue #35)
The synchronous `create_dingtalk_robot` MCP action is broken server-side:
it dispatches to a connect-engine action template (detailId G-ACT-... /
versionId G-ACT-VER-...) that the engine can no longer find, returning
PARAM_ERROR / "找不到执行动作" for every caller (the orgId in the error is
hardcoded server-side, not the caller's). The async path
(submit_robot_create_task + query_robot_create_result) still works.

`dws devapp robot create` now submits the async task and polls the result
until a terminal status, returning the same agentId/clientId/robotCode/
clientSecret payload. User experience is unchanged — one command still
creates the robot. --dry-run previews the submit call.

- runDevAppRobotCreate orchestrates submit + interval-based polling
  (pending set WAITING/PROCESSING/RUNNING/PENDING/INIT/DOING, max 24 polls).
- Tests: create routing now expects submit; add poll-until-terminal and
  status-classification unit tests.
- Docs/skills updated to reflect the new routing.
2026-06-16 09:35:05 +08:00
玉澜 8aaf0fb6e8 fix: unwrap devapp service result responses 2026-06-16 08:03:47 +08:00
玉澜 0f3c4ccbdd fix: align devapp cli with MCP schema 2026-06-16 00:31:27 +08:00
修雨 1f21fdf7be Merge pull request #36 from PeterGuy326/feat/connect-kb-observability
feat(connect): knowledge-base observability + wider text formats (#32)
2026-06-15 22:30:49 +08:00
修雨 b2fd204e67 feat(connect): knowledge-base observability + wider text formats (issue #32)
"挂载的本地知识库不生效" was hard to diagnose: a dir of pdf/docx/json indexes
nothing, and a per-message retrieval miss was silent — both look identical to
the operator. Make the knowledge path observable and a bit more forgiving.

- index the .md/.txt FAMILY (.md/.markdown/.mdx/.txt/.text), not just .md/.txt
- loadKnowledgeBase logs how many files indexed vs skipped, and the empty-dir
  error now says only text is indexed (pdf/docx/json are not)
- augment logs per message whether it injected N chunks or missed (so a wrong
  knowledge dir / non-overlapping question is visible, not silent)

Tests: isKnowledgeTextExt, markdown-family indexing, clear no-text-files error.
2026-06-15 22:27:14 +08:00
wxianfeng 6f8c9173d3 Merge branch 'feat/dws-devapp' of github.com:wxianfeng/dingtalk-workspace-cli into feat/dws-devapp 2026-06-15 21:00:31 +08:00
shangguanxuan.sgx 1921e5e37e feat(devdoc): support cursor pagination 2026-06-15 20:41:53 +08:00
wxianfeng 6480c035fa Fix review problem 2026-06-15 20:29:50 +08:00
修雨 1c88dd6a0d Merge pull request #34 from PeterGuy326/feat/connect-twin-confirm-policy
feat(connect): confirm_policy — manual / auto / remember
2026-06-15 17:30:25 +08:00
修雨 6ce4a635b2 style: gofmt connect_approval.go (struct tag alignment) 2026-06-15 17:26:59 +08:00
修雨 ee2fa735b2 feat(connect): confirm_policy — manual / auto / remember for others' requests
RoleConfig.confirm_policy was parsed but never acted on. Wire the three
strategies for how OTHERS' action requests are confirmed (the owner's own
requests always auto-run regardless):

- manual (default): ask the owner every time
- auto: run without asking — full trust — still fully audited
- remember: ask once per action verb, then reuse that decision (approve →
  auto-run same verb; reject → auto-decline same verb)

- gateDecision(requester, verb) returns auto / ask / reject
- ApprovalRequest.Verb carries the action verb (remember key + audit)
- handleOwnerDecision caches the decision per verb under the remember policy
- applyRoleConfig fills opts.ConfirmPolicy; launchConnector wires + logs it

Tests: auto runs without asking, manual asks, remember reuses the 1st decision.
2026-06-15 17:22:45 +08:00
修雨 a9df88654d Merge pull request #33 from PeterGuy326/feat/connect-twin-scope-enforce
feat(connect): enforce role capability scopes — keep a twin in its lane
2026-06-15 17:16:11 +08:00
修雨 e58e805b17 feat(connect): enforce role capability scopes — keep a twin in its lane
RoleConfig.allowed_scopes was parsed but never enforced. Now an action whose
product is outside the role's allowlist is refused before it reaches the gate,
so an HR-assistant role can't be made to touch code/drive.

- applyRoleConfig fills opts.RoleScopes from the role's allowed_scopes
- the orchestrator gains allowedScopes + scopeAllows(product); empty = allow all
- handleReply refuses an out-of-scope action up front and tells the requester
  it is out of the twin's lane (a capability boundary, not an approval, so safe
  to surface); nothing reaches the gate or the owner
- launchConnector wires opts.RoleScopes into both orchestrators and logs the
  boundary at startup

Tests: out-of-scope refused (not gated), in-scope proceeds, empty = allow all.
2026-06-15 17:13:02 +08:00
修雨 d1d6d9e74c Merge pull request #31 from PeterGuy326/feat/connect-twin-auto-retry
feat(connect): auto-retry deferred backlog — no manual 重试 needed
2026-06-15 17:07:36 +08:00
修雨 b75f07547f feat(connect): auto-retry deferred backlog — no manual retry needed
A deferred backlog (queued while the connector's dws login was not yet the
bot owner) now drains by itself once the owner's identity is back, instead of
requiring the owner to manually reply 重试.

- startAutoRetry runs a 2-minute background ticker (text mode) that calls
  autoFlushDeferred until the connector context is cancelled
- autoFlushDeferred replays the backlog but messages the owner ONLY when
  something actually completed — a tick while the identity is still wrong is
  silent (no spam); each completed requester is still notified
- flushDeferred (manual 重试) and the auto path share flushDeferredOnce
- the deferred notice now tells the owner it will auto-recover after login,
  with 重试 as an optional 'do it now'

Tests: auto-retry stays silent while stuck, drains + notifies on recovery.
2026-06-15 17:04:28 +08:00
修雨 ccb4927c48 Merge pull request #30 from PeterGuy326/feat/connect-twin-more-actions
feat(connect): twin can also create calendar events and docs
2026-06-15 16:59:37 +08:00
修雨 1a2454a5ab feat(connect): twin can also create calendar events and docs, not just todos
The digital twin previously only knew one action (todo.create). Add two more
owner-scoped write actions so it can act as a real stand-in:

- calendar.create → calendar/create_calendar_event (summary + start/end)
- doc.create → doc/create_document (name)

Both flow through the same gate: detected via [[ACTION:...]] marker, classified
as write (gated), approved/deferred/audited identically. The agent system hint
now lists all three action markers. A shared firstNonEmpty arg helper.

Tests: toPlannedAction maps each verb to the right product/tool, missing
required args degrade to not-an-action, and every action classifies as write.
2026-06-15 16:55:37 +08:00
shangguanxuan.sgx 0a8de62041 Revert "feat(skills): add self-contained dingtalk-open-platform doc skill"
This reverts commit 2c51774150.
2026-06-15 16:47:35 +08:00
修雨 d6f44143ad Merge pull request #29 from PeterGuy326/feat/connect-twin-polish
fix(connect): retry audit-sheet throttles; flush reports completed items
2026-06-15 16:37:12 +08:00
修雨 4d831e5054 fix(connect): retry audit-sheet throttles; flush reports completed items
Two polish items surfaced during the digital-twin smoke test:

- audit-sheet append now retries transient throttles (THREADPOOL_BUSY /
  timeout / 429) up to 3x with backoff, instead of silently dropping the
  audit row on the first busy response. isTransientSheetErr keeps permanent
  errors (bad node id, permission) non-retried.
- flushDeferred reports WHAT it completed (a numbered per-item list), not just
  a count. When the owner is also the requester — the common self-request
  case — this list is their only completion receipt, since notifyRequester
  skips owner==requester to avoid double-messaging.

Tests: isTransientSheetErr classification, flush per-item completion report.
2026-06-15 16:32:41 +08:00
shangguanxuan.sgxandCursor 2c51774150 feat(skills): add self-contained dingtalk-open-platform doc skill
DingTalk open-platform documentation skill as an agent execution manual
(discover -> auth -> call -> verify -> recover), fully self-contained
(no cross-skill references; dws is only a reference tool), with a
built-in doc index llm.md of verified open.dingtalk.com/document links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 15:38:21 +08:00
修雨 a10169344f Merge pull request #28 from PeterGuy326/feat/connect-deferred-execution
feat(connect): deferred execution — hold + retry when the twin can't act yet
2026-06-15 15:09:19 +08:00
修雨 82d625603f feat(connect): deferred execution — don't lose a task when the twin can't act yet
When an approved action can't execute right now (most often the connector's
dws login is not the bot owner, so an owner-scoped action like creating a
personal todo fails), the request is no longer dropped with an opaque error.
Instead it is held and retried after the owner recovers.

- new 'deferred' state: an approved request whose execution didn't complete;
  persisted like any other, distinct from terminal 'failed'
- on execution failure (auto-approve path and owner-approve path), mark
  deferred instead of failed, and privately DM the owner what went wrong and
  how to recover (log dws in as the bot owner, then reply 「重试」). The
  requester is NOT told it failed — their task is held, not lost
- 「重试/恢复」owner keyword flushes the backlog: re-execute each deferred
  request oldest-first; on success mark executed and send the requester the
  outcome, on failure keep it deferred
- markDeferred / allDeferred on the gate; deferred counts as approved() so the
  retry can still mark it executed
- tests: deferred-hold-then-retry end to end, isRetryWord

This is the graceful-degradation half of the digital twin: a request survives
an owner-offline / wrong-identity window and completes once the owner is back.
2026-06-15 15:05:45 +08:00
修雨 11780ae3b0 Merge pull request #27 from PeterGuy326/feat/connect-twin-private-approval-audit
feat(connect): private owner approval, self auto-approve, online-sheet audit
2026-06-15 14:50:50 +08:00
修雨 79bab762bf feat(connect): private owner approval, self auto-approve, online-sheet audit
Builds on the text-approval gate with the digital-twin model the owner asked
for: the approval stays strictly between the bot and its owner, and every
action is auditable in DingTalk.

- private owner DM: an action request is no longer posted into the requester's
  conversation; the bot DMs the OWNER privately (robot oToMessages/batchSend,
  reusing the bot's own credentials) and the requester sees nothing until the
  result. Decision matching moved from per-conversation to latestPending, since
  the owner replies from their own 1:1 chat, not the request conversation.
- owner self auto-approve: when the requester IS the owner, asking is the
  authorization — the action runs immediately with no second confirmation, but
  is still recorded (auto_approved=true, decided_by=owner) for audit.
- online-sheet audit: optional --audit-sheet / --audit-sheet-tab appends one
  row per terminal action (time, summary, requester, state, decided_by, auto,
  error, id) to a DingTalk online sheet via sheet append_rows, reviewable in
  DingTalk on any device — beyond the local approvals JSON.
- aiCardClient.sendOTOText: proactive 1:1 robot text send primitive.
- requester gets the final outcome (executed/failed/rejected) by 1:1 message;
  the approval process itself is never exposed to them.

Tests: parseDecisionWord, latestPending, private-DM approve/reject,
owner self auto-approve, audit terminal records, classifier wiring intact.
2026-06-15 14:47:02 +08:00
修雨 e840cac3cc Merge pull request #26 from PeterGuy326/feat/connect-text-approval
feat(connect): text approval fallback — owner confirms in chat, no card needed
2026-06-14 11:08:40 +08:00
修雨 45b4f088af feat(connect): text approval fallback — owner confirms in chat, no card needed
The confirmation gate previously REQUIRED a hand-built interactive card
template (per app, manual button param binding in the card console) — and
without one the gate silently turned off. That manual step is too clunky to
be the default. Add a text-approval mode so the digital twin works with zero
card-platform setup: when --owner-user-id is set but no --approval-card-
template, the bot posts a plain confirmation and the owner replies 「同意」/
「拒绝」 in chat to decide.

- newTextApprovalOrchestrator: gate enabled on owner alone (no card sender)
- text mode is async, NOT blocking: handleReply posts the prompt and returns;
  the owner's decision arrives as an ordinary inbound message captured by
  handleOwnerDecision (blocking on Await would deadlock the per-conversation
  worker that must also process that reply)
- gate.pendingForConv maps an owner reply (carries only the conversation) back
  to its request; parseDecisionWord matches a WHOLE-message keyword so an
  embedded 「同意」 never accidentally approves
- only the configured owner can decide; non-owner / non-keyword pass through
- devapp_connect: no card template now falls back to text mode instead of
  disabling the gate
- existing card flow refactored into handleReplyCard, unchanged behavior
- tests: parseDecisionWord, pendingForConv, text-mode approve/reject,
  non-owner rejected, non-decision pass-through
2026-06-14 11:05:13 +08:00
修雨 b28ca6364e Merge pull request #25 from PeterGuy326/feat/connect-role-runtime
feat(connect): wire role config into the connector runtime
2026-06-14 10:45:10 +08:00
修雨 8b39dac5db feat(connect): wire role config into the connector runtime
Sprint 2 item 2: a digital-employee role config (connect_role.go) was a
defined-but-unused schema. Wire it into the connector so a single
--role-config <file> supplies the bot's owner, persona and knowledge
sources instead of scattering them across flags.

- add --role-config / DWS_ROLE_CONFIG; load + validate in launchConnector
  with a client_id-must-match-this-bot guard (one role == one bot)
- applyRoleConfig merges role -> options with explicit-flag-wins semantics
  (a flag/env always overrides the role; knowledge sources are additive)
- owner feeds the approval gate; persona is prepended to every forwarded
  prompt; role knowledge sources load and merge into the same retriever
- AllowedScopes / ConfirmPolicy are parsed and logged but not yet enforced
  (scope gating + auto/remember strategies are a later slice; no dead opts
  fields carried for them)
- tests: applyRoleConfig flag-wins / fill-empty / nil-role

Scope enforcement and confirm-policy behavior intentionally deferred.
2026-06-14 10:41:37 +08:00
修雨 7ecfe85696 Merge pull request #24 from PeterGuy326/feat/connect-gate-classifier
feat(connect): gate engages via read/write classifier, not every marker
2026-06-14 10:27:16 +08:00
修雨 9af7f9a034 feat(connect): gate engages via read/write classifier, not every marker
Sprint 2 item 1: wire the dws command read/write classifier into the
digital-twin approval gate. Previously any detected [[ACTION:...]] marker
was unconditionally routed through the owner-confirmation gate. Now the
gate consults ClassifyDwsCommand on the planned command: a read-class
action bypasses the gate and replies normally, while write — and Unknown,
per the CmdClass safety contract — keep the owner sign-off requirement.

- add classifyPlannedAction bridging plannedAction -> CmdClass (classifies
  on LegacyPath, falls back to product + RPC tool verb)
- handleReply skips Submit/card/Await for a read-class action
- tests: classifyPlannedAction table + handleReply read-class bypass
  (override-driven, exercises the wiring end to end)
2026-06-14 10:22:01 +08:00
修雨 c50494775c Merge pull request #23 from PeterGuy326/feat/connect-cmd-classify
feat(connect): classify dws commands as read vs write for approval gating
2026-06-14 00:12:04 +08:00
修雨 1fe4842525 Merge pull request #22 from PeterGuy326/feat/connect-role-config
feat(connect): role config schema for per-role digital personas
2026-06-14 00:11:57 +08:00
修雨 5708e1c2d2 Merge pull request #21 from PeterGuy326/feat/connect-approval-gate
feat(connect): approval gate — execution requests need owner sign-off before running
2026-06-14 00:11:51 +08:00
修雨 d43ec228aa feat(connect): digital-twin confirmation gate vertical slice
Add the M2 confirmation gate (digital twin): an action request from a
group member is not executed directly. The bot sends an [Approve]/[Reject]
card to the owner over the existing Stream long-connection and only runs the
planned command after the owner approves.

- connect_approval.go: gate engine. ApprovalRequest model, Pending to
  Approved/Rejected to Executed/Failed state machine, thread-safe store with
  crash-safe atomic on-disk persistence (~/.dws/connect/<clientId>/approvals,
  0600, restart-recoverable), Submit/Decide/Await/Get API, and the simplified
  [[ACTION:...]] marker detection for execution-class requests.
- connect_approval_card.go: approvalCardSender boundary (DI-fakeable), the
  card-callback decoder (approval id + decision from action params, OutTrackId
  fallback), the orchestrator driving Submit to card to Await to execute to
  reply, and the real DingTalk interactive-card sender.
- connect_stream.go / devapp_connect.go: minimal wiring. Register the card
  callback router, decorate the prompt for action detection, route a marked
  reply through the gate, --owner-user-id / --approval-card-template flags.

Tests: state machine + persistence/recovery, card-action decode, reject path
(no execution), and a no-network end-to-end approve chain. gofmt/vet/build/
test all green.
2026-06-13 23:34:37 +08:00
修雨 fb335fb403 feat(connect): add dws command read/write classifier
Add ClassifyDwsCommand, a standalone heuristic classifier that labels a
dws command (by path segments) as read / write / unknown. It is the
signal a connector confirmation gate consumes to decide whether a robot
may run a command directly (read-only) or must first ask the principal
to approve it (write / mutating).

- Read/write verb tables calibrated against real dws cobra Use: verbs
  scanned from the repo (list/get/search/status/export/download vs
  create/update/delete/send/done/upload/recall/mkdir/chmod ...).
- Segments scanned right-to-left so the leaf action verb dominates a
  container/placeholder segment; falls back toward the root when the
  leaf is unrecognised (e.g. a trailing id).
- Compound leaf verbs normalised by their leading token before the
  first '-'/'_' (send-by-bot, list-forms, batch-update, add-bot ...).
- Override mechanism: per-call map (ClassifyDwsCommandWith) and a
  process-wide table (SetCmdClassOverride), keyed by single verb or
  full space-joined path, full-path winning. Overrides beat heuristics.
- Unknown is returned losslessly; the CmdClass doc comment documents the
  safety contract that callers must treat Unknown conservatively as
  write (require confirmation), never auto-allow.

This is the classifier component only; wiring it into the confirmation
gate is a separate change.
2026-06-13 23:25:04 +08:00
修雨 fd61526707 feat(connect): add digital-employee role config schema and loader
Introduce internal/helpers/connect_role.go: a standalone configuration
component for the one-role-per-bot digital-employee model. Each role binds
to a single bot via client_id and carries its persona, knowledge sources,
allowed capability scopes, owner userId and confirmation policy.

- RoleConfig struct (YAML) with manual/auto/remember confirm policy enum.
- LoadRoleConfig: read + parse + validate with clear, non-panicking errors
  (required name/client_id/owner_user_id, policy enum, source grammar).
- knowledge_sources reuse the existing parseKnowledgeSource grammar so the
  schema stays in lockstep with --knowledge-source (path / wiki: / doc:).
- LoadRoleConfigs: index roles by client_id for multi-bot deployments,
  rejecting duplicate client_id.
- RoleConfigExample documents a full role and is asserted in tests.

Schema only; no runtime wiring.
2026-06-13 23:21:16 +08:00
修雨 c858241b68 Merge pull request #20 from PeterGuy326/fix/forwarder-test-arity
fix(connect): repair forwarderForChannel test arity after #15/#16 merge
2026-06-13 23:07:09 +08:00
修雨 b826440995 fix(connect): update provider-model test for forwarderForChannel clientID arg
PR #15's connect_provider_model_test.go calls forwarderForChannel with the
old 2-arg signature; PR #16 added a clientID parameter. Both branches passed
in isolation but the merged release broke 'go test ./internal/helpers/'.
Pass an empty clientID (the test exercises model-arg injection, unrelated to
session persistence).
2026-06-13 23:03:12 +08:00
修雨 25f69b867e Merge pull request #18 from PeterGuy326/feat/connect-knowledge-wiki
feat(connect): mount DingTalk wiki space as a knowledge source
2026-06-13 22:58:23 +08:00
修雨 172060b868 Merge pull request #17 from PeterGuy326/feat/connect-daemon
feat(connect): daemonize the connector with crash-restart, status and stop
2026-06-13 22:58:17 +08:00
修雨 dd0990691e Merge pull request #16 from PeterGuy326/feat/connect-session-persist
feat(connect): persist conversation sessions across connector restarts
2026-06-13 22:58:11 +08:00
修雨 02bc7ce880 Merge pull request #15 from PeterGuy326/fix/reply-card-422-fallback
fix(connect): stop echoing provider 422 into chat; drop haiku pin for custom providers
2026-06-13 22:58:04 +08:00
修雨 4d476ed317 style: gofmt wiki knowledge source sources 2026-06-13 22:23:58 +08:00
修雨 ff89ffed69 style: gofmt connect_daemon sources 2026-06-13 22:23:48 +08:00
修雨 65ab1a3076 feat(connect): add daemon mode with crash-restart, status and stop
Turn `dws devapp robot connect` into a 7x24 background service.

- `connect --daemon` re-execs dws into a detached supervisor (POSIX setsid),
  prints pid + log path, and exits. Windows is unsupported (stub errors out).
- A supervisor process keeps a worker connector alive, restarting it with
  exponential backoff (1s..60s cap, 10 consecutive fast-failure ceiling).
- `connect status` reports running/stale/not-running, pid, uptime and log path.
- `connect stop` sends SIGTERM (worker releases the single-instance lock and
  Stream connection), escalates to SIGKILL on a timeout, and cleans the pid file.
- stdout/stderr are archived to ~/.dws/connect/<clientId>/daemon.log via the
  existing size-based rotator (new additive logging.NewRotatingFile).
- docs/connect-daemon-service.md ships launchd and systemd templates for
  boot-time auto-start.

The single-instance lock (connect_lock.go) is reused unchanged. The foreground
forwarding/session/knowledge logic is untouched; devapp_connect.go only gains
the --daemon flag, two hidden re-exec flags, and the status/stop subcommands.
2026-06-13 21:44:47 +08:00
修雨 cf277f3c8f feat(connect): add wiki/doc knowledge source for the Q&A connector
Extend the connector's knowledge retrieval (previously local .md/.txt only)
to a DingTalk knowledge base via --knowledge-source wiki:<spaceId> (and
doc:<docId>). On startup the connector pulls the space by reusing the existing
`doc` tools that back `dws doc list` / `dws doc read` (list_nodes +
get_document_content) through the shared executor.Runner -- no new DingTalk API
call is implemented. Pulled documents are dumped as markdown into a local cache
(~/.dws/connect/<clientId>/knowledge/wiki-<spaceId>/) and indexed by the same
chunker/retriever as the local-directory source.

Refresh on startup; on a pull failure (network/permission) fall back to a stale
cache if present, otherwise an empty knowledge base -- the connection is never
blocked. The legacy --knowledge-dir source is untouched and coexists.

Node listing uses a shallow id lookup so a response wrapper object is not
mistaken for a single node (which would drop sibling nodes).
2026-06-13 21:43:48 +08:00
修雨 59100407dc feat(connect): persist conversation sessions across connector restarts
convSessions held the DingTalk-conversation to agent-session map purely
in memory, so a connector restart dropped every chat's multi-turn
context - the core weakness of an always-on digital employee.

Persist the map to ~/.dws/connect/<clientId>/sessions.json (scoped per
clientId so multiple bots on one machine stay isolated), reusing the
existing config dir, 0600 FilePerm and lock-id sanitizer conventions:

- newConvSessions restores the map on startup; a missing or corrupt
  file degrades to an empty map with a warning, never a panic or a
  blocked connection.
- args/reset persist after every mutation, under the existing mutex so
  the snapshot is race-free, via a temp-file + rename atomic write.
- saves are best-effort: a write failure only logs a warning and never
  blocks message handling.
- an empty path (clientId missing or --agent-memory off) keeps the map
  in memory only, preserving the original behaviour exactly.

forwarderForChannel now takes clientId to derive the per-bot store path.
Adds unit tests for reload, corrupt-file degradation, in-memory mode and
concurrent access (race-clean).
2026-06-13 21:39:45 +08:00
修雨 10de987e81 fix(connect): stop echoing provider 422 into chat; drop haiku pin for custom providers
issue #14: the "422" seen in the group was not from the DingTalk card API but
from the claude subprocess's model provider, echoed back as the reply.

Root cause:
- claudecode's spec pins --model claude-haiku-4-5-20251001 in both argvTail and
  streamArgvTail.
- claudeUserSettingsEnv (issue #10) injects the user's ANTHROPIC_BASE_URL /
  ANTHROPIC_AUTH_TOKEN into the claude subprocess. With a third-party provider
  relay that has no mapping for that exact haiku pin, the provider returns
  "API Error: 422 ...".
- execForwarder.forward used cmd.Output() and returned any non-empty stdout as
  the answer without checking err, so the raw 422 text was forwarded to the chat
  on every message.

Fix (A + B):
A. In forwarderForChannel, when a custom provider base URL is in effect
   (injected via Claude settings or already in the process env) and the user did
   not pick a model (--agent-model / DWS_AGENT_MODEL), strip the built-in model
   pin so the provider's default model applies. Official-login users keep the
   pin; an explicit --agent-model still wins.
B. In forward and forwardStream, treat stdout that starts with "API Error:"
   (or a process error) as a failure: return a short, actionable Chinese hint
   ("...请用 --agent-model <model> 指定模型后重连.") instead of echoing the raw
   backend error. Normal replies are unaffected.

Adds unit tests for the haiku-drop wiring, the API-Error guard, and the new
helpers.
2026-06-13 14:43:24 +08:00
修雨 c36fcaaf70 Merge pull request #13 from PeterGuy326/fix/quickstart-paste-safe-placeholders
docs(quickstart): paste-safe placeholders for the connect example
2026-06-12 18:05:55 +08:00
修雨 0cf97cea55 Merge pull request #12 from PeterGuy326/fix/skill-setup-embedded-source
fix(skill): embed skill sources in binary + cover qoder dirs in upgrade refresh
2026-06-12 18:05:49 +08:00
修雨 47354b918a docs(quickstart): make the connect example paste-safe in zsh
The step-3 example used <第二步的clientId> style placeholders; pasting
them verbatim makes zsh treat the angle brackets as redirections and
fail with "parse error near \n" before the CLI even runs (issue
PeterGuy326#11). Use realistic dummy values plus an explicit replace-me
note instead, and add a FAQ entry for anyone still hitting the error
from the old wording. Also bump install snippets to v1.0.53-dws-devapp.
2026-06-12 18:00:56 +08:00
修雨 df1e33442b fix(skill): embed skill sources in the binary and cover qoder dirs in upgrade refresh
Two distribution defects left installed skills stale (issue PeterGuy326#8):

1. skill setup resolved its source from cwd / exe-adjacent checkouts, so
   an upgraded binary happily re-installed whatever stale checkout it was
   run from, and agents kept routing 'create robot' into the chat dead end.
   The skills/mono and skills/multi trees are now embedded (go:embed) and
   setup defaults to that copy, materialized into ~/.dws/skills/<mode> —
   upgrading the binary upgrades what setup installs. --source and
   DWS_SKILL_SOURCE stay as explicit overrides, and an override that has
   no skill root errors out instead of silently falling back.

2. dws upgrade's skill refresh (knownSkillDirs) skipped ~/.qoder and
   ~/.qoderwork, so Qoder/QoderWork hosts never got refreshed routing.
   Added both to the dir table and its seven mirrors (npm install.js,
   install.sh/ps1, install-skills.sh, homebrew template, package test,
   verify-package-managers.sh).

setup-side detection and --target qoder/qoderwork already work on this
branch; this closes the remaining upgrade-side and source-side gaps.
2026-06-12 17:57:48 +08:00
修雨 a7ca1e1a5b fix(connect): pass user-level Claude settings env through to the claudecode channel
The claudecode channel spawns claude with --setting-sources project to
keep the bot persona neutral, but that also drops the user-level
settings.json env block where third-party provider tools (cc-switch
etc.) store ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN. claude then
fell back to the official login and replied "Not logged in - Please
run /login" for anyone on a relayed model.

Re-expose that env block as process environment for the spawned CLI
(both one-shot and streaming paths) via the channel envFn hook.
Variables already exported by the operator are not overridden, and
user hooks/plugins still stay out of replies.

Fixes PeterGuy326#10.
2026-06-12 16:14:50 +08:00
修雨 211a06c05a fix(devapp): resolve op-app MCP endpoint from gateway base URL
The devapp endpoint was hardcoded to the pre gateway, so robot
create/list hit the pre environment for everyone. Derive it from the
configured MCP gateway base URL instead: production by default, pre
when ~/.dws/mcp_url points at the pre gateway.

Also bump quickstart doc to v1.0.51-dws-devapp and add a FAQ entry for
the production-side developer-identity check.
2026-06-12 14:54:05 +08:00
修雨 bb52a505dc docs: add customer-facing robot quickstart guide (install, create, connect, add to group) 2026-06-12 14:20:26 +08:00
修雨 f899f4f6b6 chore(release): target PeterGuy326 fork for goreleaser, guard npm publish to official repo 2026-06-12 12:04:22 +08:00
wxianfeng 3adeffb09d event subscribe 2026-06-12 11:33:55 +08:00
xianfeng wang 435bf3151c Merge pull request #9 from PeterGuy326/feat/connect-qa-hardening
feat(connect): Q&A-bot hardening — picture messages, knowledge retrieval, allowlist/rate-limit, per-chat FIFO, single-instance lock
2026-06-12 10:02:47 +08:00
修雨 1c90edb92a fix(connect): drain buffered app-server frames before honoring read EOF
The codex app-server read loop reports EOF before closing the message
channel, so when the process exits right after its final frame, both
channels are ready and select picks one at random — runTurn could drop
an already-received turn/completed and fail with "stream ended before
turn/completed: EOF" (the CI flake on this PR and #8). Drain buffered
frames first; reproduced at ~7%/300 runs before, 0/300 after.
2026-06-12 09:42:51 +08:00
修雨 36b89a04b1 Merge remote-tracking branch 'wxianfeng/feat/dws-devapp' into HEAD
# Conflicts:
#	internal/helpers/connect_stream.go
2026-06-12 09:18:10 +08:00
修雨 cdd8414891 docs: cut 1.0.37 changelog (#457) 2026-06-11 19:53:26 +08:00
Aemeath 9d3980f90a Merge pull request #8 from wxianfeng/codex/codex-channel-lab
[codex] add Codex app-server robot bridge
2026-06-11 19:49:11 +08:00
quanming ae5ef70bb8 fix: harden codex app-server client concurrency 2026-06-11 19:27:50 +08:00
xuanandshangguanxuan.sgx 2a82d07311 fix(pat): support batch agentCode and guarded grants (#455)
* fix(pat): carry agentCode in batch auth args

* fix(pat): let core default missing agent code

* fix(pat): require yes for batch grants

* test(pat): cover cli authorization matrix

* fix(pat): keep canonical agent code env only

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-11 19:21:25 +08:00
玉澜 89c3aba2ed docs(devapp): capture publish workflow edge cases 2026-06-11 13:09:27 +08:00
修雨 c151756168 feat(connect): Q&A-bot hardening for the stream bridge
Five operational gaps that bite when a connector-backed bot answers
questions in real groups:

- Single-instance lock: one connector per robot per machine (pid file,
  stale-lock takeover). Duplicate Stream connections on one clientId get
  messages load-balanced between them, so the bot answers intermittently
  (verified live).
- Access policy: --allowed-users / --allowed-groups allowlists plus a
  per-sender rate limit (--user-rate-limit, default 20/min). Every
  message is an LLM call; without this anyone who can reach the bot can
  spend on its behalf.
- Per-conversation FIFO queue: same-chat messages run in arrival order
  (follow-ups need the previous turn's --resume session; parallel CLIs
  racing one session corrupt it); different chats stay parallel.
- Picture messages: resolve the callback downloadCode via robot
  messageFiles/download to a local temp file and hand the path to the
  agent CLI — error screenshots are the top Q&A inbound and were
  previously dropped silently. The claudecode persona now permits
  reading attached files.
- --knowledge-dir: lightweight local retrieval (markdown chunking,
  latin-token + CJK-bigram scoring) prepends top-k matching chunks to
  each question while the agent keeps running from the clean scratch
  dir, so replies stay inside DingTalk's AI-assistant response window.

All knobs have env mirrors (DWS_ALLOWED_USERS / DWS_ALLOWED_GROUPS /
DWS_USER_RATE_LIMIT / DWS_KNOWLEDGE_DIR) for service-style runs.

go build ./... clean; go test ./internal/helpers -race passes with new
tests for lock/gate/queue/knowledge/media. test/cli_compat, test/scripts
and test/unit fail identically on the unmodified base (pre-existing
fixture issues).
2026-06-11 12:18:25 +08:00
玉澜 473d2f9aaf docs(devapp): document status handling 2026-06-11 12:00:27 +08:00
quanming 3dcb40c634 feat: add codex app-server robot bridge 2026-06-11 11:10:25 +08:00
玉澜 5e4b5e4eda docs(devapp): add one-click install guide 2026-06-11 10:26:17 +08:00
修雨 60ac0b409d fix(cli): guard canonical mcp tree against poisoned-cache flag collisions (#454)
* fix(cli): guard canonical mcp tree against poisoned-cache flag collisions

The canonical 'dws mcp' tree is built from cached catalog data before the
legacy command build and before Cobra dispatches anything, so a pflag
panic there (a tool schema property named after the reserved --params
flag, as cached during the 1.0.32 incident) aborted every invocation --
including 'dws cache refresh' and 'dws upgrade' -- and sat outside all
three poisoned-cache guards (#447/#449/#452).

Two layers, mirroring the existing guards:
- applyFlagSpecs now skips reserved (--json/--params), duplicate and
  alias-colliding flag names and sanitizes shorthands instead of letting
  pflag panic; the skipped property stays reachable through the reserved
  JSON payload flags (same degradation semantics as #449).
- newMCPCommand wraps the build in the #452 recover -> quarantine ->
  retry-once -> degrade-to-stub sequence, so even an unforeseen panic
  class no longer locks the CLI out.

* docs: amend 1.0.36 changelog for the re-cut with the canonical tree guard (#454)
2026-06-11 09:16:36 +08:00
john d873b9c017 Merge pull request #7 from PeterGuy326/fix/connect-card-doc-hermes-guidance
docs(connect): fix stale CardTemplate comment + document AI-assistant reply window for hermes channel
2026-06-11 09:13:19 +08:00
修雨 4b8acc8e73 docs(connect): fix stale CardTemplate comment; document AI-assistant reply window in hermes channel guidance
- ConnectOptions.CardTemplate said an empty value falls back to the public
  openclaw template, but the code (newAICardClient/hasTemplate) treats empty
  as cards-off, and the explicit "public" alias exists precisely for that
  opt-in. Align the comment with the actual contract.
- hermes official-channel steps now warn about the trap verified live: a
  devapp robot renders replies through the AI-assistant response window, so
  a slow agent's plain-text reply gets the Done reaction (HTTP 200) but never
  shows up, leaving the loading card stuck. Point users to enabling AI cards
  on the hermes side.
2026-06-10 23:54:15 +08:00
修雨 4bc4b60dca docs: cut 1.0.36 changelog (#453) 2026-06-10 22:45:50 +08:00
修雨 31e65dda51 fix(cli): self-heal a poisoned discovery cache by quarantining it and rebuilding (#452)
A panic during the envelope-driven command build no longer just degrades
to helper commands (#447): the partition's discovery cache is first moved
aside to <partition>.quarantined (kept for inspection, previous quarantine
replaced) and the build retried once against a fresh fetch. Any path that
delivers a fixed binary -- dws upgrade or a reinstall -- now escapes the
lock-out with zero manual cache surgery; only a second panic (remote
envelope still poisoned, or offline) falls back to the degraded helper
set with the 'dws cache refresh' hint.

dws upgrade additionally clears the discovery-derived caches (market /
tools / detail across all partitions, leaving the downloads dir alone)
after the binary swap, so the upgraded binary rebuilds its command tree
from fresh data instead of inheriting snapshots written by the old
version.
2026-06-10 22:45:35 +08:00
xianfeng wang 0f65cb0064 Merge pull request #6 from PeterGuy326/feat/devapp-robot-connect
feat(devapp): robot connect 回复升级——AI 卡片 + Thinking/Done 状态 + 真流式
2026-06-10 21:58:49 +08:00
修雨 f013536aac Merge upstream feat/dws-devapp: adopt upstream's hardcoded-endpoint priority
Conflict resolutions:
- internal/app/direct_runtime.go: take upstream (532fcb4) — the maintainer
  stabilized the endpoint tests by documenting the hardcoded devapp endpoint
  as authoritative (tests renamed *DoesNotOverrideHardcoded), superseding our
  demote-to-fallback fix for the same failing tests.
- skills/mono devapp.md: union — keep our reply-card/card-template/CLI
  preflight bullets and upstream's Codex DWS_AGENT_CMD guidance.
2026-06-10 21:51:25 +08:00
Aemeath c56eb7f8c2 Merge pull request #5 from wxianfeng/codex/devapp-codex-connect-skill 2026-06-10 21:48:07 +08:00
修雨 f9abc79ecc feat(devapp): cards opt-in via template; default replies are plain text + reactions
Mirror hermes' semantics: without a configured card template, replies go as
plain text/markdown with the Thinking/Done reaction chips (which need no
template and always render); cards activate only when --card-template /
DWS_CARD_TEMPLATE is set ('public' opts into the openclaw shared template
explicitly). This removes the silent-failure trap where every card API call
succeeds but the client cannot render a template it is not authorized for —
the previous default left users staring at 内容加载失败 instead of falling
back to text.
2026-06-10 21:43:42 +08:00
修雨 99893e473a feat(devapp): CLI dependency preflight in robot connect dry-run
The channel CLIs (claude/codebuddy/qodercli/...) may be missing on the
user's machine. Runtime already preflights at connect (auto-installing
package-manager CLIs, erroring with a hint otherwise), but agents had no
machine-readable way to check BEFORE starting the connector. dry-run now
reports a cli block ({required, installed, path, autoInstall, installHint})
per channel — desktop-app channels and openclaw/hermes report their
onboarding hint. Skill docs gain the agent preflight workflow: check
dry-run first; npm channels may auto-install; desktop-app channels must
have the app installed by the user before connecting.
2026-06-10 21:32:25 +08:00
修雨 51b3316187 feat(devapp): configurable AI-card template via --card-template / DWS_CARD_TEMPLATE
Card templates are app-scoped — the hermes docs prescribe grabbing the
template ID from YOUR app's AI Card settings in the developer console, and
our live A/B confirmed another app's template renders 内容加载失败. Expose
the template as a flag (default: the openclaw public template, best-effort)
so operators can register an app-owned template for reliable, branded
rendering. Skill docs updated with the developer-console path.
2026-06-10 21:18:45 +08:00
shangguanxuan.sgx 9f744caafd chore(config): default mcp endpoint to prepub 2026-06-10 21:07:13 +08:00
InternZzp 532fcb4874 fix: stabilize direct runtime endpoint tests 2026-06-10 21:07:03 +08:00
修雨 28d556c5e9 fix(devapp): demote the built-in devapp endpoint to a fallback; gofmt
The hardcoded devapp endpoint was checked BEFORE dynamic registration and
edition static/supplement lookups, short-circuiting every configurable
source — the TestDirectRuntimeEndpoint_Devapp*WithoutRegistry tests encode
the intended priority (built-in = last resort so operators can repoint the
product without a rebuild) and have been failing on the branch. Move the
built-in check after Priority 4; env override > dynamic > PAT > edition >
built-in. Also gofmt two files the Lint job flagged.
2026-06-10 21:02:22 +08:00
修雨 c106de8361 feat(devapp): AI-card replies with thinking/done states and live streaming for robot connect
Bring the official-channel reply experience (hermes/openclaw) to every
stream-bridge channel:

- "🤔Thinking" reaction chip on the user's message while the agent runs,
  swapped to "🥳Done" when the reply lands — the hermes text-emotion contract
  (POST /v1.0/robot/emotion/reply|recall, emotionId 2659900).
- AI-card reply using the openclaw connector's public template + payload
  contract (msgContent + flowStatus state machine). Card templates are
  app-scoped: hermes' own template renders "内容加载失败" for any other app —
  confirmed by live A/B on the same robot.
- Live streaming into the card where the channel CLI supports it (verified
  per CLI): claude/codebuddy stream-json text deltas (thinking deltas
  filtered), qodercli stream-json per-turn snapshots; codex/gemini/opencode
  stay one-shot. Frames are full-content and throttled at 800ms per card.
- --reply-card flag (default on, env DWS_REPLY_CARD=0) with full fallback
  chain: any card failure → plain text/markdown webhook reply; stuck cards
  are best-effort marked failed.
- Deliver responses are checked for business-level failures inside HTTP 200
  ({"result":[{"success":false,...}]} observed live), and every card API
  response body is logged for operability.
- workbuddy persona now forbids tool use: headless codebuddy otherwise
  stalls on permission gates trying to write memory files.
- Skill docs: reply-card flag, version-publish approver-selection workflow
  (check-approval → present approver list to the user → publish --approver).

Known issue under investigation: after a batch of invalid-template test
cards, the reporting device renders all subsequent cards as "内容加载失败"
even though every API call succeeds and identical payloads rendered earlier
the same day — suspected client-side card/template cache poisoning; pending
verification on a second client.
2026-06-10 20:46:38 +08:00
shangguanxuan.sgx 2e11a23381 fix(keychain): add windows storage dir for packaging 2026-06-10 20:44:59 +08:00
InternZzp 2c1be7a8fb docs: document codex robot connect command 2026-06-10 20:44:45 +08:00
shangguanxuan.sgx e637d793b0 fix(pat): verify chmod fallback agent code 2026-06-10 20:40:15 +08:00
shangguanxuan.sgx 864f94e80e docs(skill): add devdoc rag troubleshooting guidance 2026-06-10 20:20:13 +08:00
shangguanxuan.sgx 7f1d36c9ac fix(pat): require yes for batch chmod 2026-06-10 17:25:12 +08:00
shangguanxuan.sgx 4eea61897d fix(pat): guard chmod agent code mismatch 2026-06-10 16:48:52 +08:00
修雨 387ae5ff59 fix(doc): strip leading H1 duplicating --name on doc create (#448)
* fix(doc): strip leading H1 duplicating --name on doc create

The doc platform renders the document name as the page title. When the
markdown body also opens with the same H1 — a habit LLM agents fall
into despite the skill docs saying otherwise — the created document
shows the title twice.

Strip a leading ATX H1 from the markdown body when its text equals the
document name (trimmed, case-insensitive), and print a stderr note so
agents learn the convention. Any other leading H1 is kept as
intentional content; names legitimately ending with '#' are not
over-trimmed. When the body is nothing but the duplicate H1, the
markdown param is omitted entirely.

* docs(skill): note the CLI auto-strip of a duplicate leading H1 in doc create

The convention stays the same (--name is the H1, body starts from ##),
but agents should recognize the new stderr note and not rely on the
fallback.
2026-06-10 16:04:20 +08:00
修雨 838e5453d8 fix(compat): guard envelope-driven flag registration against pflag panics (#449)
The discovery envelope is remote data, but four of its shapes were
forwarded to pflag registration calls that panic:

- a flag named 'params' or 'json' collides with the reserved payload
  flags registered at the end of ApplyBindings (the original pre-1.0.32
  lockout: "chat_permission_grant flag redefined: params")
- two bindings resolving to the same long flag name (cross-binding
  duplicate primary/alias; the existing dedup map was per-binding only)
- two flags claiming the same shorthand
- a multi-character shorthand

Because the command tree is built from the cached envelope before Cobra
dispatches anything, any of these aborted every CLI invocation.

Add canRegisterFlag (skip duplicate/reserved long names; CollectBindings
already tolerates missing flags via Lookup→nil→continue, and the value
stays reachable through --params) and safeShorthand (drop invalid or
taken shorthands, keep the long flag) and apply them at every
envelope-driven registration site in ApplyBindings and
registerPositionalAliasFlags. The trailing --json/--params registration
is also made idempotent.

Complements #447: that PR adds the escape hatch when the build panics;
this removes the known panic vectors so the escape hatch should never
be needed for them.
2026-06-10 15:31:37 +08:00
修雨 330922cdee fix(cli): degrade to built-in commands when dynamic build panics (#447)
The dynamic command tree is built from cached discovery data before
Cobra dispatches any command. A panic during that build (e.g. a
duplicate pflag registration fed by a poisoned cache, as seen before
1.0.32: "chat_permission_grant flag redefined: params") aborted every
invocation — including 'dws cache refresh', the very command that
repairs the cache. The only way out was manually deleting
~/.dws/cache/<partition>/tools/*.

Wrap the envelope-driven build in a local recover: on panic the CLI now
logs the failure, prints a stderr hint pointing at 'dws cache refresh',
and falls back to the hardcoded helper commands so utility commands
stay alive and users can self-heal.
2026-06-10 15:28:58 +08:00
john c99ac87839 Merge pull request #3 from PeterGuy326/feat/devapp-robot-connect
feat(devapp): robot connect — 建联收口进 devapp 指令集(渠道感知 Stream 转发)
2026-06-10 12:08:52 +08:00
修雨 1b3319603d fix(devapp): surface Stream SDK connection lifecycle on stderr
The SDK's default logger is a doNothingLogger, so the connector printed
nothing on connect/reconnect/read errors — a dead connection was
indistinguishable from a healthy idle one. Wire an ILogger that writes
Info/Warning/Error to stderr (debug frames stay silent). Verified live:
'[stream] connect success, sessionId=...' now appears at startup.
2026-06-10 12:08:15 +08:00
修雨 995d490a76 feat(devapp): give robot connect session memory, model and workdir tuning
Users reported the linked bot feels dumb. Root cause: the forwarder was
hard-pinned to a stateless one-shot haiku run from an empty directory. Expose
the trade-offs as command-set flags instead of hardcoding them:

- --agent-memory (default on): per-conversation session resume. First message
  of a DingTalk conversation mints a UUID and passes `--session-id`,
  follow-ups pass `--resume`, so multi-turn context survives. Only on CLIs
  with addressable sessions (claudecode/codebuddy/workbuddy — verified against
  their --help); qodercli has --resume but no --session-id, so the qoder
  family stays stateless. Broken sessions self-heal: on forward error the
  conversation mapping is dropped and the next message starts fresh.
- --agent-model / DWS_AGENT_MODEL: override the channel CLI's model via each
  spec's model flag (replaces claudecode's built-in haiku pin in place).
- --agent-workdir / DWS_AGENT_WORKDIR: run the agent from a knowledge
  directory for context, instead of the clean temp dir (which stays the
  default to keep cold-start inside DingTalk's reply window).

A DWS_AGENT_CMD override disables flag splicing entirely — that argv is
user-owned and we cannot know which flags it accepts. Dry-run now previews
the effective agent tuning (model/workdir/memory) per channel.
2026-06-10 11:24:54 +08:00
修雨 5a401ddc1b fix(skill): detect ~/.qoder and ~/.qoderwork in skill setup, add qoderwork install path
Ported from PR #407: `robot connect` supports the qoder/qoderwork channels,
but skill setup neither probed ~/.qoder / ~/.qoderwork in its all-targets
checklist nor knew a qoderwork install path, so Qoder/QoderWork hosts missed
the skill routing. Consistency is enforced by TestAgentSkillPathsCoversSetupHomes.
2026-06-10 10:29:27 +08:00
修雨 f5bdb7118a feat(devapp): add robot connect to fold bot linking into the devapp command set
Bring the channel-aware Stream linking ("建联") from PR #407 into the devapp
command tree as `dws devapp robot connect`, so the full lifecycle
(create app -> create robot -> link to a local agent) lives under one
`dws devapp` domain instead of a separate `dws connect` command.

- Reuse, not duplicate: provisioning ("建号") stays on devapp's existing
  `robot create/submit/result`; this commit ports only the linking half.
- connect_stream.go: the Go-native in-process Stream forwarder + channel
  agent registry, ported verbatim from #407 (package helpers, self-contained).
- devapp_connect.go: trimmed channel routing (resolveConnectChannel /
  launchConnector / buildConnectPlan / ...) plus the new subcommand. The
  standalone `connect` cobra command, `connect start` and `connect bot create`
  are intentionally NOT brought over — the capability is consolidated under
  `dws devapp robot`.
- Credentials: `--robot-client-id` / `--robot-client-secret` for an existing
  robot, or `--unified-app-id` to auto-fetch via devapp's credentials get.
  The flags are named `robot-client-*` (not `client-id`/`client-secret`) to
  avoid shadowing the global OAuth client-override persistent flags.
- Adds the dingtalk-stream-sdk-go v0.9.1 dependency.
- Skill docs (devapp.md, robot.md mono+multi) document `robot connect`.

Known follow-up (marked in code + docs): the get_open_dev_app_credentials
response field names used by `--unified-app-id` auto-fetch are not yet
confirmed against the real gateway; the path degrades safely (empty -> fall
back to explicit flags) and must be verified against pre-prod before relying
on it.
2026-06-10 10:06:31 +08:00
shangguanxuan.sgx 4c86a9f8e1 fix(pat): carry agentCode in batch auth args 2026-06-10 09:49:53 +08:00
xuanandshangguanxuan.sgx eaa60f95b5 feat(devdoc): add rag mcp cli commands (#434)
* feat(devdoc): add rag mcp cli commands

* chore(config): default mcp endpoint to prepub

* chore(config): use prepub mcp discovery host

* fix(devdoc): wrap rag search request

* fix(transport): preserve dingtalk mcp gateway query

* fix(devdoc): align cli with rag mcp schema

* fix(config): keep mcp defaults production-safe

* test(devdoc): cover rag cli parameter mapping

* chore(test): remove dead nested arg assertion

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-09 18:00:05 +08:00
玉澜andClaude Opus 4.6 3117ad5d86 fix: restore credentials-webapp.md name, add gitignore exception
Add `!**/credentials-webapp.md` to .gitignore so the skill reference
doc is not blocked by the `credentials*` rule. Revert the cred-webapp
rename from the previous commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 17:35:09 +08:00
玉澜andClaude Opus 4.6 0e484bb189 fix: rename credentials-webapp.md to cred-webapp.md to avoid gitignore
.gitignore rule `credentials*` was blocking the file from being tracked.
Rename to cred-webapp.md, update SKILL.md links, and fix workflows.md
stale text (version is now implemented, only event remains pending).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 17:31:18 +08:00
wxianfeng de4a292174 devapp robot version 2026-06-09 15:58:01 +08:00
玉澜andClaude Opus 4.6 ad43e1ba28 docs: add pagination (--limit/--offset) to permission list skill docs
Permission list can return 150+ items. Document --limit and --offset
for paging across all three skill locations (standalone, multi, mono).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 15:43:37 +08:00
InternZzp ac825ad293 fix(devapp): use placeholders in security examples 2026-06-09 14:17:00 +08:00
玉澜andClaude Opus 4.6 8411211a8c docs: complete workflows.md with all 17 commands verified
Add missing flows: webapp get verification, member list/remove,
security config. All 17 devapp commands now E2E verified:
- member list/add/remove: tested on PR407验收 app
- security config: IP whitelist tested
- webapp get: verified returns after config

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 14:02:34 +08:00
玉澜andClaude Opus 4.6 449d731239 docs: refactor devapp skill to progressive layered structure
Restructure all three devapp skill locations (standalone, multi, mono)
from monolithic flat docs into progressive layered references:
- SKILL.md: lean routing entry (45 lines vs 325 before)
- references/app-crud.md: list/get/create/update/inactive/active/delete
- references/credentials-webapp.md: credentials get, webapp get/config
- references/permissions.md: permission list/add/remove
- references/member-security.md: member list/add/remove, security config
- references/workflows.md: end-to-end operational flows

Also adds inactive/active commands missing from prior skill docs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 13:59:15 +08:00
玉澜andClaude Opus 4.6 13d77ee7b7 feat: add credentials get command and hardcode devapp endpoint
- Add `dws devapp credentials get` helper command calling
  `get_open_dev_app_credentials` MCP tool
- Hardcode devapp endpoint to pre-mcp-gw.dingtalk.com/server/op-app
  in direct_runtime.go (same pattern as robotCreate in PR 407)
- Update docs/skills from "Pending" to "Implemented" for credentials
- Remove obsolete showSecret/confirmSecret references
- Expand lifecycle tests to table-driven covering delete/inactive/active

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-06-09 13:40:33 +08:00
InternZzp 694288cfbc feat(devapp): guard member and security writes 2026-06-09 10:41:19 +08:00
修雨 e7a3010b81 docs: cut 1.0.35 changelog + fix README product-count drift (#435)
* docs: cut 1.0.35 changelog + fix README product-count drift

CHANGELOG: promote [Unreleased] to [1.0.35] - 2026-06-08, covering the
chat @-mention render fix (#433), chat list-direct skill alignment (#424),
pat chmod batch agentCode passthrough (#414), and pat JSON auth-URL
readability (#401).

README (en + zh): the multi-skills section claimed 19/20 products while the
Key Services summary says "18 products" (aiapp was taken offline in 1.0.34,
dropping the count to 18). Unify every product-count reference to 18.

* docs(readme): tidy Key Services table — concise descriptions, drop subcommand duplication

The Description column re-listed every subcommand already shown in the
Subcommands column, making rows (esp. chat) very tall and uneven. Rewrite
descriptions as concise, parallel capability summaries and drop the
malformed inline '(top-level: ...)' sprawl in the sheet row. en + zh.

* docs(readme): restore Key Services table; keep only product-count fix

The previous commit silently rewrote/reflowed the entire Key Services
table (shortening every Subcommands + Description cell) — far beyond this
PR's stated "纯文档改动 / product-count drift fix" scope.

Restore both tables (README.md + README_zh.md) byte-for-byte to main and
keep ONLY the six intended count corrections (multi skills 20/19 → 18),
so the diff matches the PR description and the table is not re-wrapped.

* docs(changelog): write 1.0.35 entries in English

The 1.0.35 Fixed entries were in Chinese while all prior releases
(1.0.34, 1.0.33, ...) use English. Translate the four entries (#433,
#424, #414, #401) to English to match the existing CHANGELOG convention;
content unchanged.
2026-06-09 10:32:23 +08:00
修雨 e7ef2c4677 fix(chat): keep @-mention tokens literal so they render (#433)
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.
2026-06-08 21:46:41 +08:00
玉澜 6f5a0afdf7 docs: sync devapp webapp skill guidance 2026-06-08 20:48:40 +08:00
玉澜 248c6602ac Merge remote-tracking branch 'origin/feat/dws-devapp' into feat/dws-devapp
# Conflicts:
#	docs/devapp-yulan-command-routing.md
#	skills/mono/references/products/devapp.md
#	skills/multi/dingtalk-devapp/SKILL.md
#	skills/multi/dingtalk-devapp/references/devapp.md
2026-06-08 19:32:57 +08:00
玉澜 9589c23796 feat: complete devapp command routing 2026-06-08 19:07:30 +08:00
玉澜 9460437c9a test: add ambiguous app routing cases 2026-06-08 17:53:16 +08:00
玉澜 34d691906e test: cover devapp intent disambiguation 2026-06-08 17:53:16 +08:00
玉澜 a78765bb42 docs: clarify devapp branch status 2026-06-08 17:53:16 +08:00
玉澜 836f5bdb25 feat: add devapp command routing design 2026-06-08 17:53:16 +08:00
修雨 3282957958 Revert "fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)" (#415)
This reverts commit f826375556.
2026-06-08 17:53:16 +08:00
xuanandshangguanxuan.sgx 252c61aff6 fix(pat): pass agent code to chmod batch tools (#414)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-08 17:53:16 +08:00
修雨 ffefc53861 fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)
- 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.
2026-06-08 17:53:16 +08:00
InternZzp 8cbc56edd2 feat: add devapp helper commands 2026-06-08 17:24:59 +08:00
shangguanxuan.sgx fc0873b0c6 docs,test(pat): carry chmod batch auth updates 2026-06-08 15:36:57 +08:00
修雨 8c2093a41a fix(skill): align chat single-chat docs/script with list-direct (#424)
* fix(skill): align chat single-chat docs/script with list-direct

钉钉 MCP 服务已将单聊从 `chat message list` 拆出独立的 `list-direct` /
`send-direct`(list 现仅支持群聊,--user / --open-dingtalk-id 已移除),
但 skill 侧文档与脚本仍在教 `chat message list --user`,照做会因 unknown
flag 报错;推荐为单聊"优先"方法的 chat_history_with_user.py 也随之失效。

- chat.md (mono+multi): `message list` 改为仅群聊;新增 `list-direct` /
  `send-direct` 两段命令文档;意图路由 / 关键区分 / 上下文传递表 / 注意事项
  全部对齐单聊新命令
- best_practices/01-messaging.md (mono): query-private-chat 由 `list --user`
  改 `list-direct`(multi 版此前已改,未动)
- chat_history_with_user.py (mono+multi): 调用 `list-direct`;并修复返回体
  解析——解包 `result.messages` + 对齐 `createTime/content/sender` 字段,
  此前会崩在 `'str' object has no attribute 'get'`

* docs(changelog): add entry for chat single-chat list-direct alignment (#424)

* fix(skill): drop send-direct from chat docs (not a v1.0.34 command)

复核 v1.0.34 CLI 发现:单聊发送 rpc `send_direct_message_as_user` 在
v1.0.34 已并入 `chat message send`(cli_name=send,用 --user),不再暴露
独立的 `send-direct` 命令(仅 v1.0.29 有)。上一提交按 v1.0.29 误加的
send-direct 文档段/路由/关键区分/上下文表引用对 v1.0.34 是错的,全部移除。

list-direct 部分保留不变——v1.0.34 确认 `list` 仅群聊、`list-direct`
存在,`list --user` 仍报 unknown flag。单聊发送回归 `send --user`。
2026-06-07 16:17:31 +08:00
玉澜 eea85bd989 test: add ambiguous app routing cases 2026-06-05 17:08:55 +08:00
玉澜 43560afa78 test: cover devapp intent disambiguation 2026-06-05 16:30:12 +08:00
玉澜 7ef46c1288 docs: clarify devapp branch status 2026-06-05 16:20:38 +08:00
玉澜 08413a6903 feat: add devapp command routing design 2026-06-05 16:12:17 +08:00
玉澜 c488421423 Merge remote-tracking branch 'upstream/main' into feat/dws-devapp 2026-06-05 15:46:45 +08:00
修雨 5fbf12fe50 Revert "fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)" (#415)
This reverts commit f826375556.
2026-06-05 12:10:17 +08:00
xuanandshangguanxuan.sgx dd419ca498 fix(pat): pass agent code to chmod batch tools (#414)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-05 11:23:48 +08:00
修雨 f826375556 fix(cli): JSON errors to stdout; no interactive confirm on piped stdin (#413)
- 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.
2026-06-05 11:22:22 +08:00
wxianfeng b3e8783c4f Merge remote-tracking branch 'upstream/main' 2026-06-04 18:01:41 +08:00
xuanandshangguanxuan.sgx cb95207d5a fix(pat): keep auth URLs readable in JSON output (#401)
* fix(pat): keep auth URLs readable in JSON output

* fix(pat): narrow URL escaping fix to PAT JSON

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-06-04 15:57:06 +08:00
wxianfeng 478dc155e8 fix consume fail 2026-06-04 10:41:50 +08:00
wxianfeng 1c2c96617a Merge remote-tracking branch 'upstream/main' 2026-06-03 20:21:59 +08:00
wxianfeng 5fe366eddd fix conflict 2026-06-03 20:21:54 +08:00
Ari 1e95d03606 Merge pull request #405 from audanye-sudo/feat/switch-discovery-url
fix: version-code discovery path and take aiapp product offline
2026-06-03 19:45:39 +08:00
修雨 6e3f3cbd24 chore: take aiapp product offline
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.
2026-06-03 19:41:05 +08:00
audanye-sudo ce5e919c52 fix(market): version-code discovery path as /cli/discovery/apis/bamboo
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.
2026-06-03 19:41:04 +08:00
wxianfeng a7d109384b merge upstream 2026-06-03 19:14:19 +08:00
wxianfeng 08ecb38a42 dws event 2026-06-03 19:12:23 +08:00
修雨 c75ed45c70 fix(keychain): add StorageDir for Windows to unblock v1.0.33 release (#394)
* docs(chat): 资源下载分流改为按 host 判定,补 media 直链可裸 curl(整合 #376)

main 现状的「资源链接形态分流」段补三类增量,supersede #376:
- 判定依据从「链接形态」改为「链接 host,不由扩展名」
- media(down.dingtalk.com/media)点明为公开 CDN,download-media 命令之外裸 curl 也可直下
- 补 .unknown 用 file 判真实类型、图片 AI 描述说明
- 保留 download-media 官方命令路径,删去 #376 误判「无 mediaId 下载命令」一句

* docs(chat): 修正 media 下载分流——host 非唯一、首选 download-media、签名链勿裸 curl

修复 PR 原版对 media 链接的过头断言(评审发现):
- 点明图片 mediaId 链接 host 不唯一:down.dingtalk.com/media(公开)或 *.trans.dingtalk.com 签名+过期链
- 下载主路改为首选 download-media(取最新 URL,不受过期影响);裸 curl 仅限公开直链
- 删去「无需鉴权 / curl 均 HTTP 200」的普适断言(签名链过期后 curl 403)
- 保留 .unknown 用 file 判类型、AI 图片描述、钉盘/alidocs 分流

* fix(keychain): add StorageDir for Windows to unblock cross-platform release

portable_store.go (#357) references keychain.StorageDir on all platforms,
but only keychain_darwin.go / keychain_linux.go defined it — the Windows
backend (DPAPI + registry) lacked StorageDir, so GoReleaser's
windows/amd64 + windows/arm64 cross-compile failed with
'undefined: keychain.StorageDir' and broke the v1.0.33 release.

Add StorageDir to keychain_windows.go: DWS_KEYCHAIN_DIR override →
%LocalAppData%\<service> default, mirroring the linux/darwin logic.
Verified: go build passes for windows/darwin/linux x amd64/arm64.
2026-06-02 23:37:06 +08:00
修雨 a8b1670ad9 docs: 同步 CHANGELOG 1.0.33 与 README 命令清单(打 tag 前) (#393)
CHANGELOG:新增 [1.0.33] 版本块,归并 #391 整批能力(doc 文档族 / wiki 知识库 /
aiapp / aitable 表单与导入导出 / mail / todo / report 可读日志)、可迁移认证包
auth export/import (#357)、PAT 批量授权与精简摘要输出 (#389)。

README(中英双版):按当前命令树实测校准各产品命令数(contact 6→15、todo 6→16、
report 7→20、mail 4→18、wiki 7→21、oa 9→15、aitable 42→52、doc 21→28;sheet 34→23
并删除已移除的 copy_sheet/submit_export_job 等命令),总数 213→334,补全新增命令组。
2026-06-02 22:46:28 +08:00
johnand玉澜 8c4bd71964 fix(doc): align export progress output (#392)
Co-authored-by: 玉澜 <yulan.wqy@alibaba-inc.com>
2026-06-02 22:26:03 +08:00
shangguanxuan.sgx 9e7e7dbc7f Merge remote-tracking branch 'upstream/main'
# Conflicts:
#	.github/badges/coverage.svg
2026-06-02 21:30:14 +08:00
539d10f80f feat: 合并 pre-mcp-discovery 分支能力到 main(服务发现保持生产) (#391)
将 test/pre-mcp-discovery 分支上多位同学的能力改动整体合入 main,
服务发现端点保持生产 https://mcp.dingtalk.com,未携带分支上的预发改动。

主要内容:
- 修雨域(chat / report / todo / contact / mail)对齐 wukong 基线 (#355)
- DWS 内容生产能力增强 (#387)、文档导出与 OA 对齐 (#362 #388 #390)
- envelope 注册 cli.Aliases 为 cobra aliases、命令结构与 JSON 解析优化
- skills 多包同步 wukong 对齐内容(attendance/calendar/minutes/oa/sheet 等)
- product/oa 进一步对齐 wukong

排除项:未携带 566b4e1(test: point service discovery to pre-mcp.dingtalk.com);
以下 4 个常量保持生产:skill_command.go / auth/endpoints.go / cli/loader.go / market/registry.go。

Co-authored-by: john <32427341+wqyenjoy@users.noreply.github.com>
Co-authored-by: wxianfeng <wang.fl1429@gmail.com>
2026-06-02 21:04:12 +08:00
56a5edecef feat(pat): support batch chmod authorization flows (#389)
* feat(pat): support batch chmod flows

* feat: use server default agentCode for chmod

* feat: surface tool auth metadata

* feat: infer product grant auth metadata

* chore: update coverage badge [skip ci]

* feat(pat): summarize chmod output by default

* chore: update coverage badge [skip ci]

* fix(pat): preserve batch session metadata

* fix(pat): align dry-run session and schema docs

* chore(config): use public example MCP override URL

* fix(pat): align chmod session env handling

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-02 20:54:55 +08:00
shangguanxuan.sgx 7652bda320 fix(pat): align chmod session env handling 2026-06-02 20:38:38 +08:00
shangguanxuan.sgx dd1c4e34fd chore(config): use public example MCP override URL 2026-06-02 16:01:38 +08:00
shangguanxuan.sgx e653616aa3 fix(pat): align dry-run session and schema docs 2026-06-02 14:56:23 +08:00
shangguanxuan.sgx 3ce64db2fc fix(pat): preserve batch session metadata 2026-06-02 14:36:59 +08:00
github-actions[bot] 4bbd52fc58 chore: update coverage badge [skip ci] 2026-06-02 01:38:39 +00:00
shangguanxuan.sgx 9eb3099881 feat(pat): summarize chmod output by default 2026-06-02 09:36:24 +08:00
github-actions[bot] 73e2de7fe8 chore: update coverage badge [skip ci] 2026-06-01 14:18:49 +00:00
shangguanxuan.sgx 8b4f05e44e Merge release/v1.0.29 into main 2026-06-01 22:09:55 +08:00
shangguanxuan.sgx a7879edca1 feat: infer product grant auth metadata 2026-06-01 21:02:15 +08:00
shangguanxuan.sgx 3f09eb5c0f feat: surface tool auth metadata 2026-06-01 20:50:15 +08:00
修雨 4ebc8d0d38 fix(plugin): silence stdio discovery handshake failures (Warn → Debug) (#380)
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.
2026-06-01 17:11:52 +08:00
shangguanxuan.sgx 202cb509a0 feat: use server default agentCode for chmod 2026-05-29 16:48:09 +08:00
shangguanxuan.sgx dd42fd833b feat(pat): support batch chmod flows 2026-05-29 15:22:00 +08:00
4e58e45d30 feat(auth): add export/import for portable sandbox credentials (#357)
* feat(auth): add export/import for portable sandbox credentials

Enable migrating encrypted keychain and config between Linux sandboxes so refresh tokens survive beyond the 2-hour access token window.

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

* fix(auth): harden portable export/import per PR review

按 PR 审查意见加固可迁移认证包流程:macOS 默认 Keychain
场景拒绝导出;导入需 --force 覆盖已有登录态;解析 manifest
并在 OS 不匹配时告警;export 参数校验前置;flag 描述中文化;
移除已弃用的 tar.TypeRegA;导出文件后提示删除敏感包。

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

* fix(auth): require auth-token.enc before portable export

修复 staticcheck S1030(测试改用 exported.Bytes())。
导出前校验 auth-token.enc 存在,避免无登录态时生成
仅含 dek 的无效 bundle;CLI 提示先运行 dws auth login。

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

---------

Co-authored-by: 洛丘 <wangketing.wkt@antgroup.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 09:19:11 +08:00
github-actions[bot] b5f241c719 chore: update coverage badge [skip ci] 2026-05-28 04:16:11 +00:00
wxianfeng 4cdc3e7320 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-05-28 12:13:45 +08:00
wxianfeng b558bda6df Merge remote-tracking branch 'upstream/main' 2026-05-28 12:11:15 +08:00
修雨 5ce7cb61b0 feat(skill): dws skill setup with mono/multi modes (#332)
新增 `dws skill setup` 交互式安装命令,支持单产品(mono)和多产品(multi,标记为试验版)两种 skill 布局,并与开源 dws v1.0.30 命令对齐。

主要变更:
- 骨架:skills/ 拆为 skills/mono/ 和 skills/multi/(29 个产品 skill),git rename 保留历史
- 命令:新增 `skill setup` cobra 子命令,支持 --mode、--target、--source、--yes,huh 交互
  - multi 模式支持 -s/--skill、-x/--exclude 按产品选择
  - 覆盖全部 14 个 agent home 目录(与 install.sh 一致)
- 发布:install.sh / install.ps1 / post-goreleaser.sh 改读 skills/mono;dws-skills.zip 同时包含 mono 和 multi 内容;~/.dws 本地缓存
- 内容对齐:删除 8 个仅内部使用的产品 skill、dingtalk-a2a;修正 mono/multi 共 15+ 处命令引用以匹配 OSS dws v1.0.30;chat skill 从 dws-wukong 回移更新
- 漂移清理:物理删除 skill 文档中 OSS 未暴露的命令,true-fail 命令转为 caveat;--help diff 100% 覆盖审计
- 测试:新增 Layer A 静态命令调度验证 + Layer B 端到端 mock 调度验证
- 同步 main:drive 9 个命令同步到 mono/multi skill
2026-05-26 09:17:37 +08:00
修雨 86ff7e2f50 docs(changelog): add 1.0.32 release notes (#354) 2026-05-25 19:54:47 +08:00
github-actions[bot] 1063a6425d chore: update coverage badge [skip ci] 2026-05-25 06:15:24 +00:00
wxianfeng 0a8517432f Merge remote-tracking branch 'origin/main' 2026-05-25 14:12:18 +08:00
wxianfeng 83bfca8d35 Merge remote-tracking branch 'upstream/main' 2026-05-25 14:09:29 +08:00
github-actions[bot] 9e58a062f8 chore: update coverage badge [skip ci] 2026-05-25 06:06:21 +00:00
Ari 45cb237f74 fix(drive): unblock OSS upload + improve AI-agent discoverability (#347)
* 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 会先在新版本上线.
2026-05-24 15:55:08 +08:00
修雨 bd711108f9 fix(upgrade): ad-hoc sign darwin binaries to fix amfid SIGKILL on Apple Silicon (#339)
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.
2026-05-21 22:45:22 +08:00
修雨 6b4d808d39 docs(changelog): add 1.0.31 release notes (#338) 2026-05-21 20:22:57 +08:00
修雨andClaude Opus 4.7 c7d8ddf98d feat: align drive with wukong — helper (upload) + skill docs (doc/sheet dingpan URL) (#335)
* feat(helpers): drive upload 三步合成胶水(list-spaces / delete 由 envelope 接管)

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

按 envelope 优先原则拆分:

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

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

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

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

dry-run 实测:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

* fix(auth): address app config partition review

* fix(auth): clean legacy sibling app config

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

id registration and dynamicProducts remain unconditional (unaffected).

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

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

Covers what landed on main since 1.0.26:

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

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

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

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

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

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

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

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

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

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

## Scope note — MapsTo intentionally NOT included

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

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

## Tests

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

Refs #277, blocked-by #282

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

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

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

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

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

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

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* chore: update coverage badge [skip ci]

* 1.0.19 changelog

* stick opt

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

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

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

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

---------

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

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

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

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

* test(pat): preserve extra authorization route params

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-05-12 14:55:59 +08:00
xuanandshangguanxuan.sgx 19f7b59ffb fix(doc): preflight axls download (#268)
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-05-12 11:48:31 +08:00
wxianfeng aa06d9d5c9 Merge remote-tracking branch 'upstream/main' 2026-05-12 00:06:48 +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
wxianfeng bb3d1270b9 Merge remote-tracking branch 'upstream/main' 2026-05-09 11:49:26 +08:00
wxianfeng 7d40210a00 Merge remote-tracking branch 'upstream/main' 2026-05-07 16:45:19 +08:00
wxianfeng ab445813b7 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-30 10:20:38 +08:00
wxianfeng 631a3829e4 1.0.19 changelog 2026-04-30 10:20:17 +08:00
github-actions[bot] 88b4f4eeaa chore: update coverage badge [skip ci] 2026-04-30 02:17:55 +00:00
wxianfeng ee8af0c7d0 merge upstream main and fix conflict 2026-04-30 10:15:37 +08:00
github-actions[bot] 4f3501e904 chore: update coverage badge [skip ci] 2026-04-27 16:55:32 +00:00
wxianfeng b0108b9c21 merge upstream main 2026-04-28 00:53:11 +08:00
wxianfeng 4dc79fc931 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-26 11:24:22 +08:00
wxianfeng 130387eb36 Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-24 21:04:30 +08:00
github-actions[bot] 0c2b2b4703 chore: update coverage badge [skip ci] 2026-04-24 13:04:24 +00:00
xianfeng wang fed83761e5 Merge branch 'DingTalk-Real-AI:main' into main 2026-04-24 21:02:19 +08:00
wxianfeng 55eb481899 Merge remote-tracking branch 'upstream/main' 2026-04-24 20:55:47 +08:00
github-actions[bot] eb3eecab1d chore: update coverage badge [skip ci] 2026-04-24 06:51:58 +00:00
wxianfeng 3afbc046d8 Merge remote-tracking branch 'upstream/main' 2026-04-24 14:48:43 +08:00
github-actions[bot] 738b64eaf4 chore: update coverage badge [skip ci] 2026-04-23 03:30:15 +00:00
1269 changed files with 964785 additions and 36871 deletions
+1 -1
View File
@@ -1 +1 @@
<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>
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 54.2%"><title>coverage: 54.2%</title><filter id="blur"><feGaussianBlur in="SourceGraphic" stdDeviation="16"/></filter><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="#dd4343"/><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=".80" filter="url(#blur)" transform="scale(.1)" textLength="510">coverage</text><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=".80" filter="url(#blur)" transform="scale(.1)" textLength="370">54.2%</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.4 KiB

+95
View File
@@ -0,0 +1,95 @@
name: AI Behavior Check
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
statuses: write
jobs:
ai-behavior-check:
name: AI Behavior Policy Evaluator
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
# Deliberately do not check out or execute pull-request code here.
# pull_request_target keeps this policy anchored to the base branch.
- name: Check AI-generated PR boundaries
uses: actions/github-script@v7
with:
script: |
const sha = context.payload.pull_request.head.sha;
const setStatus = (state, description) =>
github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha,
state,
context: 'AI Behavior Check',
description,
});
await setStatus('pending', 'Evaluating AI-generated PR boundaries');
const labels = context.payload.pull_request.labels.map(({ name }) => name);
if (!labels.includes('ai-generated')) {
await setStatus('success', 'Not labeled ai-generated');
core.notice('Not an ai-generated PR; no AI-only policy applied.');
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
const maxChangedFiles = 30;
if (files.length > maxChangedFiles) {
await setStatus(
'failure',
`Changes ${files.length} files; limit is ${maxChangedFiles}`
);
core.setFailed(
`AI-generated PR changes ${files.length} files; limit is ${maxChangedFiles}.`
);
return;
}
const isProtectedPath = (filename) =>
typeof filename === 'string' &&
(
filename.startsWith('.github/workflows/') ||
filename.startsWith('scripts/policy/') ||
filename.startsWith('scripts/release/') ||
filename === 'test/fixtures/cli-interface-baseline.txt' ||
filename === '.goreleaser.yaml' ||
filename === 'Makefile'
);
const protectedPaths = [...new Set(
files
.flatMap(({ filename, previous_filename }) => [filename, previous_filename])
.filter(isProtectedPath)
)];
if (protectedPaths.length > 0) {
await setStatus('failure', 'Modifies protected release/CI infrastructure');
core.setFailed(
'AI-generated PR modifies protected release/CI infrastructure:\n' +
protectedPaths.map((filename) => ` - ${filename}`).join('\n') +
'\nSplit these changes into a human-owned PR with explicit review.'
);
return;
}
await setStatus(
'success',
`Passed with ${files.length} changed files (limit ${maxChangedFiles})`
);
core.notice(
`AI behavior check passed (${files.length} changed files; limit ${maxChangedFiles}).`
);
+346 -36
View File
@@ -7,8 +7,7 @@ on:
pull_request:
permissions:
contents: write
pull-requests: write
contents: read
jobs:
lint:
@@ -26,7 +25,7 @@ jobs:
- name: Format Check
run: |
unformatted="$(find cmd internal test -name '*.go' -print0 | xargs -0r gofmt -l)"
unformatted="$(find cmd internal test scripts/policy -name '*.go' -print0 | xargs -0r gofmt -l)"
test -z "$unformatted" || (printf '%s\n' "$unformatted" && exit 1)
- name: Go Vet
@@ -59,15 +58,150 @@ jobs:
run: make build
- name: Test with Race Detection
run: go test -v -race -count=1 -timeout=5m ./cmd/... ./internal/...
# The registry-first final-delivery gate validates all public commands
# and the complete generated Catalog under the race detector. Keep the
# package timeout aligned with the macOS race job so the Linux runner's
# five-minute default does not expire while that gate is still making
# progress.
run: go test -v -race -count=1 -timeout=10m ./cmd/... ./internal/...
coverage:
name: Coverage
runs-on: ubuntu-latest
- name: Test release scripts
run: go test -v -count=1 -timeout=5m ./test/scripts
test-darwin:
name: Test (macOS auth/keychain)
runs-on: macos-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Test macOS auth and Keychain paths with Race Detection
run: go test -v -race -count=1 -timeout=10m ./internal/keychain ./internal/auth ./internal/app
test-windows:
name: Test (Windows)
runs-on: windows-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build Windows CLI
run: go build -o dws.exe ./cmd
- name: Test Windows auth and DPAPI packages
run: go test -v -count=1 -timeout=10m ./internal/keychain ./internal/auth
- name: Test Windows auth migration diagnostics
run: go test -v -count=1 -timeout=5m ./internal/app -run '^TestAuth(MigrateKeychain|StatusDiagnosticReportsCiphertextKeyMismatch)'
coverage-darwin:
name: Coverage (macOS)
runs-on: macos-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Run and enforce macOS changed-code coverage
run: make coverage-gate-platform BASE_REF="$COVERAGE_BASE_REF" PROFILE=coverage-darwin.txt
- name: Upload macOS coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-darwin
path: coverage-darwin.txt
coverage-windows:
name: Coverage (Windows)
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Resolve authoritative coverage base
shell: bash
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Run and enforce Windows changed-code coverage
shell: bash
run: ./scripts/policy/run-platform-coverage-gate.sh --base-ref "$COVERAGE_BASE_REF" --profile coverage-windows.txt
- name: Upload Windows coverage artifact
uses: actions/upload-artifact@v4
with:
name: coverage-windows
path: coverage-windows.txt
coverage:
name: Coverage
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
@@ -80,11 +214,58 @@ jobs:
- name: Build
run: make build
- name: Run tests with coverage
- name: Resolve authoritative coverage base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
go test -coverprofile=coverage.txt -covermode=atomic ./cmd/... ./internal/...
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
echo "COVERAGE_BASE_REF=$base_ref" >> "$GITHUB_ENV"
- name: Run current and baseline unit tests with coverage
run: |
set -eu
go test -count=1 -p 1 -coverprofile=coverage.txt -covermode=atomic ./ ./cmd/... ./internal/... ./skills/...
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic ./pkg/... ./scripts/policy/...
go test -count=1 \
-run '^(TestAllShortcuts|TestCrossPlatformCoverage)' \
-coverpkg=./internal/app,./internal/helpers,./internal/shortcut/... \
-coverprofile=coverage-shortcut.txt \
-covermode=atomic \
./internal/app ./internal/helpers ./internal/shortcut/...
base_worktree="$(mktemp -d "${RUNNER_TEMP}/dws-coverage-base.XXXXXX")"
rmdir "$base_worktree"
cleanup() {
git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true
}
trap cleanup EXIT
git worktree add --detach "$base_worktree" "$COVERAGE_BASE_REF"
(
cd "$base_worktree"
go test -count=1 \
-p 1 \
-coverprofile="$GITHUB_WORKSPACE/coverage-base.txt" \
-covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
)
go tool cover -func=coverage.txt
- name: Enforce coverage gate
env:
COVERAGE_TARGET: "80"
COVERAGE_ENFORCE_OVERALL: "false"
run: COVERAGE_ADDITIONAL_PROFILE=coverage-shortcut.txt make coverage-gate BASE_REF="$COVERAGE_BASE_REF"
- name: Generate coverage report
run: go tool cover -html=coverage.txt -o coverage.html
@@ -94,32 +275,11 @@ jobs:
name: coverage-report
path: |
coverage.txt
coverage-base.txt
coverage-policy.txt
coverage-shortcut.txt
coverage.html
- name: Update coverage badge
if: github.ref == 'refs/heads/main'
run: |
COVERAGE=$(go tool cover -func=coverage.txt | grep total | awk '{print $3}' | sed 's/%//')
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE >= 80" | bc -l) )); then
COLOR="brightgreen"
elif (( $(echo "$COVERAGE >= 60" | bc -l) )); then
COLOR="yellow"
else
COLOR="red"
fi
mkdir -p .github/badges
curl -s "https://img.shields.io/badge/coverage-${COVERAGE}%25-${COLOR}" > .github/badges/coverage.svg
- name: Commit badge
if: github.ref == 'refs/heads/main'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add .github/badges/coverage.svg || true
git diff --staged --quiet || git commit -m "chore: update coverage badge [skip ci]"
git push || true
policy:
name: Policy Check
runs-on: ubuntu-latest
@@ -139,8 +299,98 @@ jobs:
- name: Policy
run: make policy
- name: Generated Drift
run: ./scripts/policy/check-generated-drift.sh
interface-integrity:
name: Interface Integrity
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: make build
- name: Resolve authoritative compatibility merge-base
env:
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
set -eu
base_ref="$PUSH_BEFORE_SHA"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
base_ref="$(git merge-base "$PR_HEAD_SHA" "$PR_BASE_SHA")"
fi
if [ -z "$base_ref" ] || [ "$base_ref" = "0000000000000000000000000000000000000000" ]; then
base_ref="$(git rev-parse HEAD^)"
fi
git rev-parse --verify "${base_ref}^{commit}" >/dev/null
stable_ref="$(git tag --merged "$base_ref" --list 'v[0-9]*' --sort=-version:refname | awk 'index($0, "-") == 0 { print; exit }')"
if [ -z "$stable_ref" ]; then
echo "No stable release tag is reachable from compatibility base $base_ref" >&2
exit 1
fi
git rev-parse --verify "${stable_ref}^{commit}" >/dev/null
echo "COMPATIBILITY_BASE_REF=$base_ref" >> "$GITHUB_ENV"
echo "COMPATIBILITY_STABLE_REF=$stable_ref" >> "$GITHUB_ENV"
- name: Check historical commands and help compatibility
run: |
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_BASE_REF"
if [ "$(git rev-parse "${COMPATIBILITY_BASE_REF}^{commit}")" != "$(git rev-parse "${COMPATIBILITY_STABLE_REF}^{commit}")" ]; then
make authoritative-interface-integrity \
BASE_REF="$COMPATIBILITY_STABLE_REF"
fi
- name: Check complete Schema compatibility
run: make schema-compatibility BASE_REF="$COMPATIBILITY_BASE_REF"
- name: Check skill command references
run: make skill-command-integrity
cli-smoke:
name: CLI Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: make build
- name: Check public top-level commands
run: make cli-smoke
mock-mcp-smoke:
name: Mock MCP Smoke
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check HTTP and stdio MCP transport
run: make mock-mcp-smoke
edition-tests:
name: Edition Contract Tests
@@ -158,11 +408,71 @@ jobs:
- name: Run edition contract tests
run: go test -v -count=1 ./pkg/editiontest/...
ci-gate:
name: CI Gate
needs:
- lint
- test
- test-darwin
- test-windows
- coverage
- coverage-darwin
- coverage-windows
- policy
- interface-integrity
- cli-smoke
- mock-mcp-smoke
- edition-tests
if: ${{ always() }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
steps:
- name: Verify required checks
env:
LINT_RESULT: ${{ needs.lint.result }}
TEST_RESULT: ${{ needs.test.result }}
TEST_DARWIN_RESULT: ${{ needs.test-darwin.result }}
TEST_WINDOWS_RESULT: ${{ needs.test-windows.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
COVERAGE_DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
COVERAGE_WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
POLICY_RESULT: ${{ needs.policy.result }}
INTERFACE_INTEGRITY_RESULT: ${{ needs.interface-integrity.result }}
CLI_SMOKE_RESULT: ${{ needs.cli-smoke.result }}
MOCK_MCP_SMOKE_RESULT: ${{ needs.mock-mcp-smoke.result }}
EDITION_TESTS_RESULT: ${{ needs.edition-tests.result }}
run: |
failed=0
for check in \
"Lint:$LINT_RESULT" \
"Test:$TEST_RESULT" \
"Test (macOS auth/keychain):$TEST_DARWIN_RESULT" \
"Test (Windows):$TEST_WINDOWS_RESULT" \
"Coverage:$COVERAGE_RESULT" \
"Coverage (macOS):$COVERAGE_DARWIN_RESULT" \
"Coverage (Windows):$COVERAGE_WINDOWS_RESULT" \
"Policy Check:$POLICY_RESULT" \
"Interface Integrity:$INTERFACE_INTEGRITY_RESULT" \
"CLI Smoke:$CLI_SMOKE_RESULT" \
"Mock MCP Smoke:$MOCK_MCP_SMOKE_RESULT" \
"Edition Contract Tests:$EDITION_TESTS_RESULT"
do
name="${check%%:*}"
result="${check#*:}"
printf '%s: %s\n' "$name" "$result"
if [ "$result" != "success" ]; then
failed=1
fi
done
test "$failed" -eq 0
notify-downstream:
name: Notify Wukong Overlay
needs: [test, policy, edition-tests]
needs: [ci-gate]
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
permissions: {}
steps:
- name: Trigger downstream CI
run: |
+88
View File
@@ -0,0 +1,88 @@
# 把本仓库代码自动镜像到 Gitee,供国内用户访问(raw 脚本入口 + tags)。
# 用 HTTPS + 令牌直接 git push(无需 SSH key),复用已配置的 secret:
# GITEE_TOKEN —— Gitee 私人令牌(勾 projects)
# GITEE_USER —— 令牌所属 Gitee 用户名(用于 https 推送鉴权)
# GITEE_REPO —— "owner/repo",如 DingTalk-Real-AI/dingtalk-workspace-cli
# 未配置 GITEE_TOKEN 时(如 fork)自动跳过,不报红叉。
name: Mirror code to Gitee
on:
push:
branches:
- main
tags:
- 'v*'
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
concurrency:
group: gitee-code-mirror
cancel-in-progress: false
jobs:
mirror:
runs-on: ubuntu-latest
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
steps:
- name: Checkout (full history + tags)
if: env.GITEE_TOKEN != ''
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push main + tags to Gitee (with README localization)
if: env.GITEE_TOKEN != ''
run: |
set -eu
REMOTE="https://${GITEE_USER}:${GITEE_TOKEN}@gitee.com/${GITEE_REPO}.git"
if [ "${GITHUB_REF_TYPE:-}" = "tag" ]; then
VERSION="$GITHUB_REF_NAME" ./scripts/release/sync-gitee-tag.sh
echo "✅ 已镜像 tag ${GITHUB_REF_NAME} 到 Gitee ${GITEE_REPO}"
exit 0
fi
# 取到 main 与所有 tag(落到 origin/* 与本地 tags,避免推当前分支引用冲突)
git fetch --force --tags origin 'refs/heads/main:refs/remotes/origin/main'
# Gitee 专属分支:在 origin/main 之上叠加一个 README 本地化 commit。
# GitHub 那份 README 不变;只有推往 Gitee 的副本被改写。
git checkout -B gitee-main origin/main
git config user.email "actions@github.com"
git config user.name "github-actions[bot]"
# 1) 安装命令本地化:raw.githubusercontent → gitee raw(国内可达)。
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#raw.githubusercontent.com/${GITEE_REPO}/main#gitee.com/${GITEE_REPO}/raw/main#g" "$f"
done
# 2) coverage 徽章:仓库内相对路径 svg 在 Gitee 渲染不出来(gitee raw 对 svg
# 返回需签名、会过期的 URL,且 content-type 为 text/plain)。改成 shields.io
# 静态徽章——数值取自仓库 coverage.svg,颜色按覆盖率阈值。
SVG=".github/badges/coverage.svg"
if [ -f "$SVG" ]; then
PCT="$(grep -oE '[0-9]+(\.[0-9]+)?%' "$SVG" | head -1)"
NUM="${PCT%\%}"; INT="${NUM%.*}"
if [ "${INT:-0}" -ge 80 ]; then C=brightgreen; elif [ "${INT:-0}" -ge 60 ]; then C=yellow; else C=red; fi
BADGE="https://img.shields.io/badge/coverage-${NUM}%25-${C}"
for f in README.md README_zh.md; do
[ -f "$f" ] || continue
sed -i "s#\.github/badges/coverage\.svg#${BADGE}#g" "$f"
done
fi
git add README.md README_zh.md 2>/dev/null || true
git commit -m "docs(gitee): localize install commands + coverage badge for Gitee mirror" || true
# 镜像对齐(force:Gitee 始终跟随 GitHub + Gitee 专属 README 本地化)
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
# Release tags are immutable. Push only missing tags and fail closed
# on a conflicting existing ref instead of trying to move it.
timeout --signal=TERM 180s git push --tags "$REMOTE"
echo "✅ 已镜像 main(+Gitee README 本地化) + tags 到 Gitee ${GITEE_REPO}"
+54
View File
@@ -0,0 +1,54 @@
name: Multi Profile E2E
on:
pull_request:
push:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: multi-profile-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
multi-profile-e2e:
name: Multi Profile E2E
runs-on: ubuntu-latest
timeout-minutes: 15
env:
MULTI_PROFILE_E2E_LOG: .tmp-bin/multi-profile-e2e.log
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run isolated multi-profile chain
shell: bash
run: |
set -o pipefail
mkdir -p .tmp-bin
bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir | tee "$MULTI_PROFILE_E2E_LOG"
{
echo "### Multi Profile E2E"
echo "- Command: \`bash scripts/dev/test-multi-profile-e2e.sh --keep-workdir\`"
echo "- Scope: isolated auth/profile storage, profile switch/use, one-shot profile override, CSV multi-profile aggregation, legacy migration"
echo "- Result: passed"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload debug artifacts
if: failure()
uses: actions/upload-artifact@v4
with:
name: multi-profile-e2e-debug
path: |
.tmp-bin/multi-profile-e2e.*/out
.tmp-bin/multi-profile-e2e.log
if-no-files-found: ignore
retention-days: 3
+71
View File
@@ -0,0 +1,71 @@
name: Publish npm release
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to publish to npm (e.g. v1.0.48)"
required: true
type: string
permissions:
contents: read
jobs:
publish-npm:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Stage npm package
run: |
set -eu
version="${{ inputs.version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
+285 -3
View File
@@ -5,12 +5,18 @@ on:
tags:
- "v*"
workflow_dispatch:
inputs:
repair_npm_version:
description: "Only publish an existing release to npm, e.g. v1.0.48"
required: false
type: string
permissions:
contents: write
jobs:
release:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
runs-on: ubuntu-latest
timeout-minutes: 30
@@ -20,6 +26,19 @@ jobs:
with:
fetch-depth: 0
- name: Check Homebrew PR automation token
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
# Organization policy intentionally prevents the broad, built-in
# GITHUB_TOKEN from creating PRs. Keep Formula automation on a
# repository-scoped token instead of weakening that policy.
env:
HOMEBREW_PR_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
run: |
if [ -z "${HOMEBREW_PR_TOKEN:-}" ]; then
echo "HOMEBREW_PR_TOKEN is required to open Formula PRs from official releases" >&2
exit 1
fi
- name: Set up Go
uses: actions/setup-go@v5
with:
@@ -28,6 +47,53 @@ jobs:
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
- name: Multi Profile E2E
run: bash scripts/dev/test-multi-profile-e2e.sh
- name: Install rcodesign (sign darwin binaries from Linux)
run: |
set -euo pipefail
RCS_VERSION="0.29.0"
RCS_ARCHIVE_SHA256="dbe85cedd8ee4217b64e9a0e4c2aef92ab8bcaaa41f20bde99781ff02e600002"
curl -fsSL -o /tmp/rcodesign.tar.gz \
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
printf '%s %s\n' "$RCS_ARCHIVE_SHA256" /tmp/rcodesign.tar.gz \
| sha256sum --check --strict -
mkdir -p /tmp/rcodesign
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
rcodesign --version
- name: Prepare Apple Developer ID certificate
env:
APPLE_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_CERTIFICATE_P12_BASE64 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
set -euo pipefail
if [ -z "${APPLE_CERTIFICATE_P12_BASE64:-}" ] || [ -z "${APPLE_CERTIFICATE_PASSWORD:-}" ]; then
if [ "$GITHUB_REPOSITORY_OWNER" = "DingTalk-Real-AI" ]; then
echo "APPLE_CERTIFICATE_P12_BASE64 and APPLE_CERTIFICATE_PASSWORD are required for official releases" >&2
exit 1
fi
echo "Developer ID secrets are unavailable; fork release will use ad-hoc signing."
exit 0
fi
umask 077
certificate_path="$RUNNER_TEMP/dws-developer-id.p12"
password_path="$RUNNER_TEMP/dws-developer-id-password"
printf '%s' "$APPLE_CERTIFICATE_P12_BASE64" | base64 --decode > "$certificate_path"
printf '%s' "$APPLE_CERTIFICATE_PASSWORD" > "$password_path"
# Fail before packaging if the secret is corrupt or the password is wrong.
# The exported P12 may use legacy PKCS#12 ciphers; OpenSSL 3 requires
# -legacy to validate those containers even though rcodesign can read them.
openssl pkcs12 -legacy -in "$certificate_path" -passin "file:$password_path" -noout
echo "DWS_APPLE_CERTIFICATE_P12=$certificate_path" >> "$GITHUB_ENV"
echo "DWS_APPLE_CERTIFICATE_PASSWORD_FILE=$password_path" >> "$GITHUB_ENV"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
@@ -40,12 +106,128 @@ jobs:
run: ./scripts/release/post-goreleaser.sh
env:
DWS_PACKAGE_VERSION: ${{ github.ref_name }}
DWS_REQUIRE_DEVELOPER_ID_SIGNING: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
- name: Upload dws-skills.zip to release
- name: Remove Apple Developer ID certificate
if: ${{ always() }}
run: |
rm -f "$RUNNER_TEMP/dws-developer-id.p12"
rm -f "$RUNNER_TEMP/dws-developer-id-password"
# GoReleaser uploads the original archives to a Draft before
# post-goreleaser.sh replaces the Darwin binaries. Re-upload every changed
# file, verify the Draft digests, and keep it private for Apple validation.
- name: Upload finalized signed assets to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
DWS_PUBLISH_RELEASE: "false"
run: ./scripts/release/finalize-github-release.sh
- name: Preserve finalized distribution files
uses: actions/upload-artifact@v4
with:
name: finalized-release-dist
path: dist/
if-no-files-found: error
retention-days: 1
verify-darwin-signatures:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
needs: release
runs-on: macos-latest
timeout-minutes: 10
steps:
- name: Download finalized Darwin assets from Draft release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "${{ github.ref_name }}" dist/dws-skills.zip --clobber
set -euo pipefail
mkdir -p dist
gh release download "$GITHUB_REF_NAME" \
--repo "$GITHUB_REPOSITORY" \
--dir dist \
--pattern 'dws-darwin-amd64.tar.gz' \
--pattern 'dws-darwin-arm64.tar.gz' \
--clobber
- name: Verify finalized Darwin signatures with Apple codesign
run: |
set -euo pipefail
for arch in amd64 arm64; do
archive="dist/dws-darwin-${arch}.tar.gz"
stage="$RUNNER_TEMP/verify-darwin-${arch}"
mkdir -p "$stage"
tar -xzf "$archive" -C "$stage"
test -f "$stage/dws"
codesign --verify --strict --verbose=4 "$stage/dws"
codesign -dvvv "$stage/dws"
done
publish-release:
if: ${{ github.event_name != 'workflow_dispatch' || inputs.repair_npm_version == '' }}
needs:
- release
- verify-darwin-signatures
runs-on: ubuntu-latest
# The optional Gitee fallback has its own bounded retry budget and may be
# enabled during a cross-border incident. Normal releases skip that step.
timeout-minutes: 120
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Restore finalized distribution files
uses: actions/download-artifact@v4
with:
name: finalized-release-dist
path: dist
- name: Publish verified Draft release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release edit "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" --draft=false
- name: Open stable Homebrew formula PR
# Beta builds must never replace the stable Homebrew formula. Formula
# updates use the normal PR path instead of writing main from a tag job.
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
run: ./scripts/release/publish-homebrew-formula.sh
env:
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
DWS_TAP_PR_BRANCH: "automation/homebrew-${{ github.ref_name }}"
DWS_TAP_PR_TITLE: "chore: update Homebrew formula for ${{ github.ref_name }}"
DWS_TAP_COMMIT_MESSAGE: "chore: update formula for ${{ github.ref_name }}"
- name: Open beta Homebrew formula PR
# Keep beta in a separately named, keg-only Formula so it cannot replace the
# stable dws link for ordinary Homebrew users.
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }}
run: ./scripts/release/publish-homebrew-formula.sh
env:
DWS_FORMULA_SOURCE: dist/homebrew/dingtalk-workspace-cli-beta.rb
DWS_TAP_FORMULA_PATH: Formula/dingtalk-workspace-cli-beta.rb
DWS_TAP_REPO_URL: https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
DWS_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_PR_TOKEN }}
DWS_TAP_PR_REPOSITORY: ${{ github.repository }}
DWS_TAP_PR_BRANCH: "automation/homebrew-beta-${{ github.ref_name }}"
DWS_TAP_PR_TITLE: "chore: update Homebrew beta formula for ${{ github.ref_name }}"
DWS_TAP_COMMIT_MESSAGE: "chore: update beta formula for ${{ github.ref_name }}"
- name: Sync release to China OSS mirror
# 自动同步到国内镜像,供 install.sh 的 DWS_RELEASE_BASE 开关消费。
# 脚本自带门控:未配置 OSS_* secret 时优雅跳过,不影响海外发布。
run: ./scripts/release/sync-to-oss.sh
env:
VERSION: ${{ github.ref_name }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
OSS_ENDPOINT: ${{ secrets.OSS_ENDPOINT }}
OSS_BUCKET: ${{ secrets.OSS_BUCKET }}
OSS_PREFIX: ${{ secrets.OSS_PREFIX }}
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -53,8 +235,108 @@ jobs:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish to npm
- name: Publish stable to npm
# 只有官方仓库发 npm;fork(dev 预览)没有 NPM_TOKEN,跳过以免红叉。
# 必须在 Gitee mirror 前发布:Gitee 附件上传偶发长时间挂住,不能阻塞 npm/latest。
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(github.ref_name, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
# 预发布版本不能更新 npm latest,避免普通 npm 安装链路拿到 beta。
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(github.ref_name, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
mirror-gitee-release:
# Keep the optional cross-border fallback out of publish-release so all
# pre-sync work has an independently provable budget.
if: ${{ vars.ENABLE_GITEE_UPLOAD_FALLBACK == 'true' }}
needs:
- release
- publish-release
runs-on: ubuntu-latest
timeout-minutes: 120
steps:
- name: Check out repository
uses: actions/checkout@v4
timeout-minutes: 5
- name: Restore finalized distribution files
uses: actions/download-artifact@v4
timeout-minutes: 10
with:
name: finalized-release-dist
path: dist
- name: Mirror release to Gitee (China)
timeout-minutes: 100
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
repair-npm:
if: ${{ github.event_name == 'workflow_dispatch' && inputs.repair_npm_version != '' }}
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Download GitHub release assets
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.repair_npm_version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Stage npm package
run: |
set -eu
version="${{ inputs.repair_npm_version }}"
semver="${version#v}"
pkg_root="dist/npm/dingtalk-workspace-cli"
rm -rf "$pkg_root"
mkdir -p "$pkg_root/assets" "$pkg_root/bin"
cp build/npm/install.js "$pkg_root/install.js"
cp build/npm/bin/dws.js "$pkg_root/bin/dws.js"
cp build/npm/README.md "$pkg_root/README.md"
sed "s|__VERSION__|${semver}|g" build/npm/package.json.tmpl > "$pkg_root/package.json"
cp dist/dws-* "$pkg_root/assets/"
cp dist/checksums.txt "$pkg_root/assets/"
test -f "$pkg_root/assets/dws-skills.zip"
cat "$pkg_root/package.json"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Publish stable to npm
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && !contains(inputs.repair_npm_version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish prerelease to npm beta
if: ${{ github.repository_owner == 'DingTalk-Real-AI' && contains(inputs.repair_npm_version, '-') }}
working-directory: dist/npm/dingtalk-workspace-cli
run: npm publish --access public --tag beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,55 @@
name: Sync release to Gitee
# Manually mirror a published GitHub release's assets to the matching Gitee
# release. Use this to repair a release whose Gitee mirror is incomplete (e.g.
# the Release job timed out mid-upload). It runs ONLY the idempotent Gitee sync
# step — it does not run GoReleaser and does not touch the GitHub release, so
# there is no release outage. The sync script skips assets already on Gitee, so
# this only uploads what is missing.
on:
workflow_dispatch:
inputs:
version:
description: "Release tag to mirror to Gitee (e.g. v1.0.42)"
required: true
type: string
permissions:
contents: read
jobs:
sync-gitee:
runs-on: ubuntu-latest
# Each step has its own ceiling. Their 115-minute sum leaves five minutes
# for runner scheduling/teardown inside this 120-minute job deadline.
timeout-minutes: 120
steps:
- name: Check out repository
uses: actions/checkout@v4
timeout-minutes: 5
- name: Download GitHub release assets
timeout-minutes: 10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -eu
mkdir -p dist
gh release download "${{ inputs.version }}" \
--repo "${{ github.repository }}" \
--dir dist \
--pattern 'dws-*' \
--pattern 'checksums.txt' \
--clobber
ls -la dist
- name: Mirror release to Gitee (China)
# Idempotent: uploads only assets not already present on the Gitee release.
timeout-minutes: 100
run: ./scripts/release/sync-to-gitee.sh
env:
VERSION: ${{ inputs.version }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_USER: ${{ secrets.GITEE_USER }}
GITEE_REPO: ${{ secrets.GITEE_REPO }}
+23
View File
@@ -19,6 +19,7 @@ test/cli_compat/testdata/
/internal/compat/testdata/*
.gitignore
.worktrees/
.qoder/
# Secrets & credentials
.env
@@ -26,8 +27,30 @@ test/cli_compat/testdata/
*.pem
*.key
credentials*
!skills/**/credentials.md
plans
_docs
dws.zip
*.code-workspace
/dingtalk-workspace.zip
# envelope/discovery.pre.json synced via Portal, not git-tracked
/envelope/discovery.pre.json
# local/pre-release MCP service configs may contain personal gateway keys
/docs/mcp/serviceconfig-pre*
# 功能测试运行产物
results.jsonl
test/dev_functional/results.jsonl
/auto-test/
/eval-runs/
/.qoder/
.vercel
.env*
# Local Go coverage output
/coverage.txt
/coverage-base.txt
/coverage-policy.txt
/coverage.html
dwsbin
+5 -2
View File
@@ -64,9 +64,12 @@ changelog:
release:
github:
owner: DingTalk-Real-AI
# 用当前运行 CI 的仓库 owner: fork CI 发到 fork, 官方 CI 发到官方, 两边都对
owner: "{{ .Env.GITHUB_REPOSITORY_OWNER }}"
name: dingtalk-workspace-cli
draft: false
# Keep the release private until post-processing has replaced the Darwin
# archives and verified every finalized asset digest.
draft: true
prerelease: auto
name_template: "v{{.Version}}"
mode: replace
+51
View File
@@ -0,0 +1,51 @@
name: Gitee Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
version:
description: "Release tag to build on Gitee, e.g. v1.0.48"
required: false
type: string
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Install packaging tools
run: |
set -eu
sudo apt-get update
sudo apt-get install -y zip unzip curl
- name: Install rcodesign
run: |
set -eu
RCS_VERSION="0.27.0"
curl -fsSL -o /tmp/rcodesign.tar.gz \
"https://github.com/indygreg/apple-platform-rs/releases/download/apple-codesign%2F${RCS_VERSION}/apple-codesign-${RCS_VERSION}-x86_64-unknown-linux-musl.tar.gz"
mkdir -p /tmp/rcodesign
tar -xzf /tmp/rcodesign.tar.gz -C /tmp/rcodesign --strip-components=1
sudo install -m 0755 /tmp/rcodesign/rcodesign /usr/local/bin/rcodesign
rcodesign --version
- name: Build and publish Gitee release
env:
VERSION: ${{ inputs.version || github.ref_name }}
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
GITEE_REPO: DingTalk-Real-AI/dingtalk-workspace-cli
run: ./scripts/release/build-and-publish-gitee.sh
+357
View File
@@ -0,0 +1,357 @@
# Repository Agent Guide
This file applies to the entire repository. Keep changes scoped, preserve
unrelated work, and use `gofmt` for every modified Go file.
## Build and test
- Build: `go build ./cmd`
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
- Generate Schema assets: `go generate ./internal/cli`
- Check generated drift: `./scripts/policy/check-generated-drift.sh`
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
Generated Schema JSON is committed. Change its source inputs and generators,
then regenerate; do not hand-edit generated Catalog or Agent metadata files.
`internal/cli/schema_command_registry.json` is different: it is a reviewed
`CommandRegistry` source, not a generated snapshot. It is the single reviewed
source of stable canonical identity,
primary paths, aliases, and navigation. Edit it only when reviewed exposure,
identity, primary path, or aliases change; parameter, Skill, and metadata-only
changes must not rewrite it mechanically.
## Agent Schema contract
The Schema data flow is one way:
```text
1. app.NewRootCommand()
└─ builds the real Cobra command tree and flags
2. schema_command_registry.json
+ schema_hints/metadata/<product>.json tool parameters (+ cli_path)
└─ forms EffectiveCommandRegistry
└─ binds exactly to real Cobra leaves and aliases
3. Parameter resolution
Cobra flags
+ schema_parameter_bindings.json
+ metadata tool parameters
└─ produces ParameterSpec and constraints
4. Agent and interface semantics
schema_hints/selection/<product>.json (selection prose)
+ schema_hints/metadata/<product>.json (safety/interface/runtime_gate)
+ pinned MCP metadata
└─ resolves Agent metadata by source precedence
Markdown is evidence only; it is not concatenated into final prose
5. One typed hub
BoundCommandRegistry
+ ParameterSpec
+ Agent metadata
+ Interface metadata
└─ resolves every command exactly once into ToolSpec
└─ aggregates SchemaRegistry + SchemaIndex
6. One-way publication
SchemaRegistry
└─ internal/cli/schema_catalog.json
└─ dws schema list/product/group/leaf/--all
```
Parameter overlays from metadata are merged into `EffectiveCommandRegistry`
*before* Cobra binding; after that point there is no second identity source and
no identity precedence winner. The binder must reject a missing/non-runnable
Cobra path, an alias collision, and any native identity annotation that
disagrees with the effective registry. A missing native identity annotation is
allowed because annotations are implementation-side assertions, not identity
fallbacks.
The assembler resolves every bound command exactly once into one `ToolSpec`.
Build-time gates and the snapshot serializer consume that source-resolved typed
registry/index. Runtime projections and delivery gates consume the typed
registry/index returned by the production snapshot loader. Neither path may
reopen annotations, merge source records, or use a previous Catalog or other
generated JSON as a source. `schema_catalog.json` is output-only in the
generation graph. The production loader decoding the embedded published
snapshot is a delivery boundary, not source resolution; it must never create or
repair a Cobra command, flag, registry entry, or later Catalog generation.
This split is architecturally isomorphic to Lark's typed metadata registry,
navigation catalog, and schema renderer. DWS intentionally preserves its
existing flat JSON wire contract for compatibility; do not treat architectural
alignment as permission to make an unversioned wire-format change.
The reviewed `CommandRegistry` is the sole source of stable command identity
and navigation. The executable Cobra tree remains the source of truth for
whether a CLI path exists, is runnable, and which flags it accepts. Schema
coverage is bidirectional:
1. Every final `SchemaRegistry` tool, including its serialized Catalog
projection, must resolve to an executable Cobra command.
2. Every public runnable Cobra leaf must either resolve to Schema or appear as
an exact, reviewed exclusion with a non-empty reason in
`internal/cli/schema_command_exclusions.json`.
Do not use prefix or wildcard exclusions: they can silently hide future
commands. Remove an exclusion when its command enters Schema; stale, invalid,
or duplicate exclusions must fail generation and CI.
When adding or changing an Agent-visible command, review all relevant inputs:
- `internal/cli/schema_command_registry.json` for the reviewed
`CommandRegistry`: canonical identity, primary CLI path, aliases, and stable
navigation. It is the identity source and is not a generated artifact.
- `internal/cli/schema_command_registry.schema.json` is its closed,
machine-readable editing contract. Preserve the local `$schema` reference;
unknown fields, invalid visibility values, stale paths, and collisions fail
Go validation and policy.
- `internal/cli/schema_hints/metadata/<product>.json` for safety, interface,
`runtime_gate`, and optional parameter overlays (`parameters` / `cli_path`).
- `internal/cli/schema_hints/selection/<product>.json` for reviewed Agent
selection prose (`agent_summary`, `use_when`, `avoid_when`, `examples`).
- `internal/cli/schema_hints/index.json` only maps product IDs to those files.
- Native Runtime Schema identity annotations, when present, as consistency
assertions against `EffectiveCommandRegistry`. They must agree exactly and
must never materialize, infer, or override registry identity.
- Flag-to-interface property mappings and required/default semantics.
- Generated files under `internal/cli/schema_agent_metadata/` and
`internal/cli/schema_catalog.json` after running generation.
Run the reverse-completeness tests whenever the Cobra tree changes. A command
that works through `dws <path>` but cannot be found through the matching
`dws schema` lookup is a contract failure unless it has a reviewed exact
exclusion.
Metadata parameter overlays must reference an exact public runnable Cobra leaf
and real flags. They may override Schema description, interface-property/type
mapping, `required`, and `required_when`; they must not create commands or
flags, define an interface, or advertise an unknown RPC. Every authored entry
requires `reviewed: true` and a non-empty review reason.
For Agent-authored metadata or selection edits:
1. Confirm the exact command and flag names in the current Cobra tree.
2. Edit only the owning block (`metadata/` or `selection/`); do not mix fields.
3. Add the smallest possible entry; do not copy generated Catalog fields into
the input.
4. Describe user-visible semantics in `review_reason` and parameter
descriptions.
5. Run generation, drift, Schema policy, and the focused CLI tests before
proposing the change.
## Agent curation workflow (Schema hints)
Use this workflow when refreshing Agent selection prose and confirmation
alignment. Prefer **agent-authored review** over bulk merge scripts that dump
`selection-review.json` or Skill Markdown into Catalog fields.
Human-authored inputs are split into two blocks:
| Block | Path | Owns |
|---|---|---|
| **metadata** | `internal/cli/schema_hints/metadata/<product>.json` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / `runtime_gate` / optional `parameters` |
| **selection** | `internal/cli/schema_hints/selection/<product>.json` | `agent_summary` / `use_when` / `avoid_when` / `examples` (+ product routing) |
`index.json` only maps product IDs to those files. Do not mix selection fields
into metadata files or metadata fields into selection files.
### Goals
1. **Selection prose** is decision-oriented (Feishu/Lark style): trigger intent,
sibling-command routing, and outcome shape — not a restatement of the
summary. Delivered Catalog provenance is `reviewed_explicit` from
`selection/`.
2. **Safety** follows Runtime: `confirmation=user_required` iff the tool's
metadata `runtime_gate != none` (for example `confirm_delete`, `typed_yes`,
`confirm_dangerous`).
3. **Parameter overrides** (former Manual `commands`) live on metadata tools as
`parameters` (+ `cli_path`) and are applied into EffectiveCommandRegistry.
### Authoring
For every curated tool:
1. Edit `metadata/<product>.json` for safety/interface/gates/parameters.
2. Edit `selection/<product>.json` for selection prose (`reviewed: true`,
`review_reason`, `source_refs`).
3. Run `make generate-schema`. Do not hand-edit generated
`schema_agent_metadata/` or `schema_catalog.json`.
### Pull live MCP descriptions (personal token)
Pinned `internal/cli/schema_mcp_metadata.json` is a sanitized baseline. Prefer
live Schema from a logged-in personal session:
```bash
dws auth status # token_valid should be true
dws cache refresh # refresh discovery / tools cache
dws schema <mcp-canonical> -f json
# or CLI path: dws schema --cli-path "drive copy" -f json
```
Resolve MCP identity via `interface_ref` when CLI canonical ≠ MCP path
(example: CLI `drive.copy_document` → live `doc.copy_document`). On pull
failure, fall back to Skill + Cobra Help + pinned MCP, and record evidence
(for example `live-dws-schema:<path>#FAILED`). Never print or commit tokens.
Precedence when sources disagree: **Runtime/Cobra > live MCP > pinned MCP >
Skill (evidence only)**.
### Parallel product agents
Split work by product groups. Each agent must:
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
for its tools.
- Hand-write selection + metadata; forbid wholesale JSON merges from review
dumps.
- Edit only its `metadata/<product>.json` and `selection/<product>.json`.
- **Never** `git checkout` unrelated product files to “clean scope”.
### Regenerate and gates
```bash
make generate-schema
./scripts/policy/check-runtime-confirmation-truth.sh
go test ./internal/app -run '^TestSheetFinalSchemaConfirmationMatchesRuntimeGuards$' -count=1
```
Example rules (fail generation otherwise):
- At most two examples per tool; no `--yes` in stored examples.
- Examples must match live Cobra argv (path, flags, required groups).
- No shell comments in examples.
After generation, spot-check Catalog: selection provenance is
`reviewed_explicit` from `selection/`, and `user_required` count equals
metadata `runtime_gate != none`.
`make generate-schema` is a full deterministic snapshot rebuild, not an
incremental patch over the previous Catalog. It rereads every reviewed input,
removes stale generated product metadata, and rewrites the exact metadata and
Catalog projections. Incremental work happens only when an Agent or human
edits selected `metadata/` or `selection/` entries; the next publication still
recomputes all outputs. Generated files must never be read back as merge input,
and byte guards fail generation if it changes the hint inputs or CommandRegistry.
Selection prose may choose a more or less restrictive recommendation. It cannot
create a Cobra command or flag, change parameter facts, invent an
RPC/interface, alter safety metadata, or bypass command completeness. Examples
must use an executable primary/alias path and flags accepted by the live Cobra
command; never add `--yes` to stored examples.
Every example is always checked against its real `BoundCommand`: exact path,
accepted flags, Cobra required flags/positionals, and the effective
`require_one_of`, `require_together`, and `mutually_exclusive` constraints must
all pass before execution eligibility is considered. A missing required value,
constraint failure, runtime error, or MCP resolution error is a contract bug;
none is a valid reason to skip an example.
Example execution defaults to contract validation only. Runtime execution is
opt-in: an example enters `dry_run` only when its final `ToolSpec` publishes an
explicit reviewed dry-run capability. The test never injects `--yes`, and
`risk`/`confirmation` values do not manufacture preview support. A narrow
runtime precondition that cannot be derived from the typed contract may use an
exact zero-based `example_dispositions` entry with `mode=contract_only`,
`reviewed=true`, one of the schema-enumerated reason codes, and a concrete
non-empty reason. Such a disposition may only narrow an explicit dry-run
capability; it cannot turn an ordinary contract-only example into a skip.
Duplicate, missing, and out-of-range indexes fail validation. Never catch a
dry-run failure and dynamically downgrade it to `contract_only`.
Normal Go tests run the exhaustive contract gate. Run
`make test-schema-agent-examples` to additionally execute the eligible subset
through the real Cobra `--dry-run` path with isolated HOME and blocked proxies.
The test reports stable `total`, `contract`, `dry_run`, `contract_only`,
`reviewed_manual`, and per-reason counts; changing those counts requires a
review of the corresponding typed dry-run capability or manual disposition.
This target is also part of `make policy`.
Treat every tool `use_when` entry as a reviewed positive selection scenario
whose expected result is that tool's canonical path, and every `avoid_when`
entry as a reviewed negative scenario that must not choose that tool. The
deterministic gate derives a typed evaluation fixture from these same fields;
it requires exact tool coverage, a real runnable `BoundCommandRegistry`
primary command, at least one positive and negative assertion per tool, and no
literal contradictory expectations. It does not claim that string matching
proves natural-language understanding.
Semantic selection is an explicit opt-in live-model check. Run the smoke set
(one positive and one negative scenario per product) with
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestManualAgentSelectionArkLive -count=1`.
Add `DWS_AGENT_SELECTION_FULL=1` to evaluate every committed tool scenario, or
set `DWS_AGENT_SELECTION_CASES` to comma-separated fixture case IDs. Normal CI
never calls a model; its blockers remain the reproducible fixture, binding,
example, provenance, and final-delivery facts.
The live evaluator sends only case IDs/scenarios plus one same-product
candidate table; expected/forbidden assertions stay local and must never be
included in the model prompt. Built-in Ark HTTPS bases are allowlisted. A
different HTTPS provider requires its exact base in
`DWS_AGENT_SELECTION_ALLOWED_BASE_URLS`; plaintext HTTP is accepted only for a
loopback test server so API credentials are never sent to an arbitrary clear
text endpoint.
## Safety metadata
Parameter and safety resolution is mostly source-precedence based and
value-neutral: do not choose a winner because one value looks stricter. A
higher-priority reviewed metadata/explicit source may intentionally raise or
lower description, mapping, `effect`, `risk`, `confirmation`, or `idempotency`.
Preserve all candidates and the selected source in provenance, and fail
same-precedence conflicts rather than silently merging them.
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
final Agent projection must keep `required=true` and cannot be lowered by
manual/hint overlays. Overlays may still raise an optional flag to required.
`cli_required` continues to mirror the executable Cobra marker.
For command text, reviewed `ToolSchemaHint` wins first, then command-specific
Cobra Help, then MCP metadata. Generic RPC prose may remain an unselected
provenance candidate (and parameter-level `interface_description`); it must not
overwrite a specialized leaf's title or description.
For every delivered `ToolSpec` and `ParameterSpec` field, the provenance
winner value must exactly equal the delivered value. Checking only source,
count, presence, or hash is not a sufficient final-delivery invariant.
The same resolved `ToolSpec` must drive every projection. The full leaf payload
must equal the corresponding tool in `schema --all` and the full Catalog tool.
Overview/product/group summaries and Catalog summaries must equal
`ToolSpec.ToSummaryPayload()`. An alias lookup may change only the view fields
`cli_path` and `is_alias`; it must not re-resolve or mutate the command
contract.
This build-time rule is distinct from runtime drift handling. If shipped Help
and leaf Schema disagree, pass only flags accepted by Cobra. For conflicting
safety information, do not silently take the less restrictive behavior: use
the safer interpretation or stop and report the contract drift.
Do not infer one safety field from another. In particular, `effect=destructive`
or `risk=high` does not mechanically rewrite `confirmation`; the final
precedence winner for each field is authoritative. When
`confirmation=user_required`, obtain confirmation before adding `--yes`.
Keep CLI confirmation behavior and Schema metadata consistent, and add a
semantic regression test through the final embedded loader/query delivery
path; a generator unit test or JSON count alone is insufficient.
## Current Schema boundaries
- `schema list` remains a progressive overview. `schema --all` is the stable
full-export contract: every final `SchemaIndex` tool must contain its
complete leaf parameters, constraints, and safety semantics, including an empty
`parameters` object for commands without flags. Keep it suitable for the #602
compatibility baseline and fail rather than silently emitting a partial
export.
- `schema --all` is not normal command discovery. Use overview -> product/group
-> leaf for routine Agent work. `--compact` is supported for context-saving
projections, but a compact full export is not a complete compatibility
baseline.
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
executable accepts. A leaf Schema defines Agent selection, parameter mapping
and constraints, and safety/confirmation semantics. A conflict is contract
drift, not permission to guess.
- Schema and Help describe commands; neither returns DingTalk business data.
After discovery, execute the real read/search/list command to obtain data.
+505
View File
@@ -4,6 +4,511 @@ 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/).
## [Unreleased]
### Added
- **Declarative shortcut commands** (#592) — adds 366 `dws <service> +<command>` shortcuts across 16 services, including one-to-one MCP wrappers and multi-step smart workflows. Shortcuts publish stable Agent-visible contracts with named flags, validation and confirmation metadata, dry-run protection for writes, catalog/help routing, and optional local YAML extensions and usage recording.
- **Sheet imports and Aitable workflow writes** (#624) — adds `dws sheet import` / `sheet import create` for converting local xlsx/xls files into new online sheets, `sheet import get` for polling import tasks, and `dws aitable workflow create/update` for applying validated `workflow-dsl/v1` definitions, with matching reviewed Agent Schema and bundled Skill guidance.
- **Official multi-platform Homebrew channel** — stable `Formula/dingtalk-workspace-cli.rb` and keg-only `Formula/dingtalk-workspace-cli-beta.rb` live in this repository and select signed macOS Intel/Apple Silicon or Linux amd64/arm64 artifacts at install time. Stable and beta releases open isolated Formula update PRs after final artifact signing, so beta never replaces the stable Formula. Agent Skills stay under `pkgshare` without mutating the user's home directory, and both tracks are covered by the six-channel post-release verifier.
### Fixed
- **Sheet and Todo invalid-target failures** — `sheet range read/get` now rejects a null cell-info response instead of printing `null` and exiting successfully, while Todo completion and attachment listing verify that a task exists before calling lenient backend endpoints. Attachment listing is also published through Runtime Schema for schema-first Agent discovery.
## [1.0.52] - 2026-07-14
This release seals the `v1.0.52` line with personal event subscriptions, a deterministic 22-product Agent command catalog, local user-operation auditing, expanded Open product commands, safer macOS credentials and release signing, and more reliable Connect and IM delivery.
### Added
- **Personal event subscriptions** (#589) — adds `dws event list/schema/consume/status/stop` for user @ mentions, selected one-to-one chats, and selected group chats. `consume` can create or reuse a personal subscription, multiple local consumers share one bus while keeping outputs isolated by event type and subscription, and the mono/multi event Skills ship with the binary.
- **Open product command capabilities** (#608) — adds Sheet table, pivot-table, and gridline commands; Chat message favorites; Drive statistics and shortcuts; and Doc comment update/delete, with matching mono/multi Skill documentation and command-contract coverage.
- **Local user-operation audit log** (#555) — operations executed through `dws` now produce redacted daily JSONL records with actor, command and endpoint, result or error category, duration, CLI/platform metadata, and a SHA-256 previous-hash chain for tamper evidence. Writers coordinate through a cross-process file lock and rotate logs safely; `dws audit tail` inspects recent records, `dws audit export` emits date-filtered JSONL or CSV, and `dws audit verify` reports the first broken link in a file's hash chain.
- **Stable Agent command catalog** (#598) — `dws schema` now ships a deterministic 22-product / 564-tool catalog generated from the executable Cobra tree, with progressive product/group/leaf queries, complete parameter contracts, reviewed command identity and aliases, safety/confirmation metadata, field provenance, and final-delivery completeness/drift gates. The catalog is embedded at build time and does not require runtime MCP `tools/list` discovery.
- **Reviewed Schema for local commands** (#598, #609) — `event consume/list/schema/status/stop` and `audit export/tail/verify` enter the reviewed `CommandRegistry`, bind to the real Cobra tree at generation time, and ship through the same typed `ToolSpec` and embedded Catalog path as public MCP-backed commands. Leaf, group, product, and `--all` queries are projections of that single delivered model.
- **Safe macOS Keychain → file-DEK migration** (#597) — `dws auth migrate-keychain --to file-dek` preflights every legacy/profile auth entry before rewriting, ignores unrelated application secrets, supports side-effect-free `--dry-run`, requires explicit `--yes`, and lets sandboxed and normal processes share an existing login without exposing tokens.
### Changed
- **`event consume` AI-subprocess contract** (#609) — emits a fixed ready line and a final controlled-exit summary, supports parent-pipe stdin EOF as graceful shutdown, forwards `--profile` to the detached bus, surfaces bus startup errors, and cleans up subscriptions according to ownership so orchestrators can drive event streams without sleeps or leaked server-side subscriptions.
- **Wukong IM read-result parity** (#618) — `chat message list` preserves quoted merged-forward and image context; message-search entitlement failures retain the server-provided friendly hint and action URL; and `ding message list` exposes each DING's content alongside its ID and status.
- **Developer ID signing for official macOS archives** (#605) — official releases now require both Darwin archives to be signed with the configured Apple Developer ID certificate, timestamp, and hardened runtime. The release job validates credentials and signatures and fails closed instead of silently publishing ad-hoc-signed official binaries.
### Fixed
- **Smart-category mappings and runtime network diagnostics** (#591) — `chat category create-smart` now maps category names, group-name keywords, and member OpenDingTalk IDs to the live MCP contract, rejects blank or empty supplied values locally, and reports runtime `tools/call` connection failures as actionable API/network errors instead of internal discovery failures.
- **Connect daemon restart lifecycle** (#599) — pins the Stream SDK reconnect-race fix, snapshots the running executable before detaching, uses a real 30-second keepalive, and manages each worker as its own Unix process group so launcher cleanup or worker panics no longer cause restart loops or orphan local-agent processes.
- **Complex Connect messages and attachments** (#606, #612) — rich-text messages retain all embedded pictures in order, queued turns keep every pending attachment, and unknown or future callback shapes reach each Agent backend with their message type and raw JSON instead of being discarded. Attachment recovery is locator-based, nested `chatRecord` pictures/audio/video/files can be recovered from message APIs after Stream ACK, and OpenCode uses a full-duration storyboard for large videos to avoid base64 OOMs while preserving the original download for the turn.
- **macOS auth survives Keychain mode changes** (#597) — credential reads try existing compatible DEKs without creating key material, updates preserve the DEK that decrypted existing ciphertext, unreadable slots fail closed before token exchange, profile slots use the canonical auth backend, and `auth status` reports ciphertext/key mismatches instead of treating them as ordinary logout. Dedicated macOS race and Windows DPAPI coverage protect the cross-platform paths.
## [1.0.51] - 2026-07-10
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
### Added
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
### Changed
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
### Fixed
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
## [1.0.50] - 2026-07-08
This release fixes a long-standing gap where the global `--jq` / `--fields` output filters were silently ignored on product commands, lands a JSON-mode output path for the sheet batch-style command, and aligns the bundled skill surface with the real command semantics uncovered by the round-2 real-machine QA sweep.
### Fixed
- **Global `--jq` / `--fields` are honored on product commands** (#575) — `Formatter.PrintJSON` / `PrintJSONUnescaped` now route through `output.WriteFiltered` when either flag is set, so product commands accept the same filters that `dws api` has always supported. The tool-caller adapter exposes `Fields()` / `JQ()` so helpers can read the flags without re-parsing.
- **`skill setup --dry-run` is a no-op preview** (#575) — it now prints what would be written without touching the skill directory, the registry, or the agent config. Help text and docs are updated to match.
- **Skill docs alignment to the real command surface** (#575) — per-product references and the cross-product intent guide clarify that `--fields` projects top-level / list keys only (use `--jq` for nested paths); `minutes_extract_todos.py`, `calendar_free_slot_finder.py`, `chat_export_messages.py` / `chat_history_with_user.py`, and `contact_dept_members.py` are rewritten against the current response shapes; `aisearch` / `aitable` / `attendance` / `calendar` / `chat` / `contact` / `dev` / `doc` / `doc-comment` / `doc-file-ops` / `doc-list` / `doc-search` / `drive` / `mail` / `minutes` / `oa` / `sheet` / `sheet-export` / `url-patterns` / `best_practices/lite-recipes.md` / `global-reference.md` / `intent-guide.md` are re-synced; the QA voice ("真机" phrasing) and environment-specific quirks stated as absolute rules are removed from the docs.
### Changed
- **`sheet range batch-set-style` emits per-row JSON in JSON mode** (#575) — when `--format json` is set, each update is reported as `{index, sheetId, range, ok, error}` instead of only the final aggregate, so callers can programmatically track partial failures under `--continue-on-error`.
- **Command-merge helpers exported** — `pkg/cmdutil.LeafMerge*` and the provenance helpers are now public so downstream command trees can reuse the same merge semantics.
## [1.0.49] - 2026-07-08
This release lands a full real-machine QA sweep across the CLI, helper scripts, and skill docs (#572), and hardens the release pipeline so npm publishing can no longer be blocked by Gitee mirror issues (#570).
### Fixed
- **Real-machine QA fixes across CLI commands** (#572) — `aitable chart/dashboard share update --enabled` now takes a string so `--enabled false` disables; `chat conversation-info --user` resolves openDingTalkId and registers `--id/--conversation-id/--chat` aliases; `chat list-all-conversations --limit` is capped at 100 and rejects larger values; custom-robot webhook failures surface `errcode` instead of masquerading as success; `contact` registers `--dept/--depts` as the primary flags so the documented spelling actually works; `sheet media-upload` and `sheet export` emit clean JSON under `--format json` (progress lines no longer leak); `wiki node create --type` enum is corrected (drops unsupported `asheet`, adds `axls/able/appt/adraw/amind`); `ding message list --type` defaults to `ALL` since the server rejects empty type.
- **Helper script fixes (mono and multi)** (#572) — aitable import/export flag names and the tableId regex (7-char default tables were rejected); mail search `--limit`, contact dept response keys (`deptList`/`deptUserList`) and `userInfo` nesting; `attendance_my_record` whoami compatibility; `calendar_schedule_meeting` event-id unwrapping; `drive_tree_list` recursion via `fileId`; report scripts migrated off the deprecated `report list`/`report detail`.
- **Skill docs sync (mono and multi)** (#572) — command indexes, flag names, enums, return-structure keys and cross-product intent routing are re-aligned to real-machine behavior across all products. Genuinely server-side limitations (permission gates, org-level restrictions, unregistered tool keys) are annotated instead of code-patched, and the cross-cutting hazards (`success` always true, `--jq`/`--fields` currently no-op) are documented.
### Changed
- **Release pipeline unblocks npm publish from Gitee mirror** (#570) — the Release workflow now publishes to npm before touching the Gitee mirror, so Gitee upload issues cannot block `npm/latest`. GitHub→Gitee attachment upload is disabled by default (unreliable from US runners) and only runs when `ENABLE_GITEE_UPLOAD_FALLBACK=true`; the legacy upload fallback path is guarded with timeout and retry so it fails fast when re-enabled.
- **Repair modes for release republish** (#570) — the Release workflow gains a repair input and a standalone npm-only repair workflow, used to republish an existing release to npm without re-running the full pipeline.
## [1.0.48] - 2026-07-07
This release promotes the sealed **remove-discovery delivery** from the beta line to the stable `v1.0.48` package. It removes dynamic service discovery from the open-edition runtime, keeps legacy CLI compatibility aliases, syncs the open command/help/skill surface with the dws-wukong baseline, and includes the `dev connect` default-yolo behavior on the stable upgrade track.
### Changed
- **Remove-discovery delivery is now formal/stable** — the beta validation line is ready to cut as `v1.0.48`; normal stable channels (`dws upgrade`, GitHub `releases/latest`, install scripts, and npm `latest`) should receive this release after the official tag is published.
- **Static endpoint runtime sealed for stable delivery** — the open edition no longer depends on dynamic service discovery at runtime, while preserving legacy command compatibility aliases and the synced help/skill surface from the beta.
- **`contact label` is restored as real wukong-compatible functionality** — `dws contact label list/get/list-members` now call `get_org_labels`, `search_label_by_name`, and `get_label_members_by_labelId`; `contact role` remains an alias, and the common top-level compatibility entries (`contact search/find/list/get/self/me/whoami/get-self`) now dispatch to real user/dept/label tools where unambiguous.
- **Skill docs match the sealed command surface** — contact docs again describe the real `contact label` three-step role lookup flow; video-conference start/invite/share flows remain explicitly unsupported and point users to the DingTalk client.
### Fixed
- **`calendar event list --dry-run` no longer executes the real list call** — the sorted event-list wrapper now respects dry-run and prints the `list_calendar_events` preview instead of calling the backend.
- **`chat file upload` is downlined** — the hidden compatibility entry now returns a clear downline message and never calls `chat/upload_conversation_file_by_url`; the supported file path remains `chat message send --msg-type file --file-path`.
- **Optional plugin version validation no longer pollutes every command** — incompatible local plugins such as conference are skipped at debug level during command-tree construction instead of printing a WARN on unrelated commands.
- **PR #45 review follow-ups are folded into the release** — doc version rollback pagination now unwraps nested result/content/data envelopes for `nextCursor`, mail helper scripts handle `{result:{emailAccounts:[...]}}`, and the generated attendance `.xlsx` fixture is removed from the skill scripts.
### Tests
- **Command-surface regression tests** — root-command tests now cover real `contact label`/`role` dry-runs, hidden top-level contact compatibility entries, `chat file upload` downline behavior, and `calendar event list --dry-run`.
- **Release hygiene tests** — skill markdown policy still blocks unsupported conference routes, plugin loader tests assert optional validation failures stay quiet at WARN level, and doc version cursor extraction has nested-envelope coverage.
## [1.0.47] - 2026-07-05
This release adds **connector supervision & health monitoring** (`dev connect list/status/restart/stop`) and fixes **bot-to-bot @-mention** delivery end-to-end.
### Added
- **`dev connect list`** — PM2-style colored table enumerating all local connectors with state (healthy / degraded / down / not_running), PID, channel, and uptime.
- **`dev connect status`** — panel view with heartbeat, last recv timestamp, session webhook age, and `--json` for external monitoring.
- **`dev connect restart`** — restarts a daemon via persisted `daemon-state.json` (unified-app-id credential fetch, no local secret storage).
- **`dev connect stop`** — graceful SIGTERM shutdown releasing the single-instance lock and Stream connection.
- **Health watchdog** — background goroutine writes `heartbeat.json`; `status`/`list` derive state from heartbeat freshness + process liveness + pid-reuse detection.
- **`--alwayson` flag** — opt-in auto-restart: supervisor relaunches the worker on crash (requires `--daemon`).
- **`--notify-staff-id`** — state-change notifications (start / stop / crash) sent as DingTalk messages to the specified staffId.
- **`--unified-app-id` credential flow for `dev connect`** — fetches clientId/clientSecret at startup via `dev app credentials get`, keeping secrets off the command line and out of `daemon-state.json`.
- **API-sent file download** (`feat(connect): download API-sent files via storage v2 API`) — file messages sent via `dws chat message send --msg-type file --dentry-id --space-id` are now downloaded by the connector through the storage v2 `getDownloadInfo` API (dentryId + spaceId → presigned URL → local temp file), so file-based Q&A works regardless of how the file was sent.
- **`--at-open-dingtalk-ids` for `chat message send-by-bot`** — @-mention bots or cross-org users by openDingTalkId in group messages.
### Fixed
- **Bot-to-bot @-mention send side** — `atOpendingtalkIds` (the server's lowercase spelling) is now used instead of the camelCase `atOpenDingTalkIds` which was silently ignored. The unnecessary `openDingTalkId → userId` reverse lookup (always failed for bots) is removed; the id is forwarded verbatim.
- **Bot-to-bot @-mention receive side** — `interactiveCard` messages (how DingTalk delivers a bot @-mentioning another bot) are now parsed: `extractInteractiveCardText` flattens `cardContent[].children[].value` leaves and strips the leading @-mention by leaf boundary. The `emotion/reply` reaction (which 500s on bot-sent cards) is skipped for `interactiveCard` turns.
- **Markdown/richText body extraction** — `extractCallbackText` gains a `cardContent` fallback so structured-text messages are no longer silently dropped.
- **Send-by-bot @ chip rendering** — `<@id>` placeholders in the markdown body are rewritten to `@id` for both userIds and openDingTalkIds so the mention chip renders in all cases.
- **Connector retry on transient network errors** — `sendBySession` retries on transient failures instead of dropping the reply.
- **Orphan worker cleanup & watchdog deadlock** — stale workers from a crashed supervisor are detected and cleaned; a channel-capacity fix prevents the watchdog from blocking.
- **Idle connector false-down** — heartbeat ticker now advances `updatedUnix` so a connector with no inbound traffic is not marked degraded.
- **FD limit check** — `checkFDLimit` split into platform files for Windows cross-compilation.
- **Default agent timeout removed** — no timeout by default (was incorrectly defaulting to a low value).
- **keepAlive shortened to 30 µs** — aligns with Stream SDK expectations; adds `ulimit` check for multi-agent stability.
## [1.0.46] - 2026-07-01
### Fixed
- **PAT agentCode grants no longer split from follow-up command checks** (`internal/auth/agent_code_detect.go`, `internal/app/runner.go`, `internal/pat/chmod_test.go`) — explicit `DINGTALK_DWS_AGENTCODE` declarations are now forwarded verbatim as the common cross-host contract, and unknown hosts no longer synthesize `custom` into `x-dingtalk-dws-agent-code` / `x-dws-agent-instance-id`. `pat chmod --agentCode` remains the highest-priority grant target and still wins over the env fallback.
## [1.0.45] - 2026-06-29
This release adds **multi-organization (profile) support** (#500): `dws` can stay logged in to several DingTalk organizations at once and switch between them, while staying fully backward/forward compatible with the previous single-org token. A profile is one logged-in organization (corp); the current profile decides which org a command runs against. The release also hardens the new credential store for concurrency and corruption recovery, documents the capability in both the mono and multi skill sets, and flips `--ai-tag` on by default so messages sent through `dws` carry the DingTalk 「通过AI发送」 badge (#524).
### Added
- **Multi-organization login & `profile` management** (`internal/auth/profiles.go`, `internal/app/profile_command.go`) — `dws auth login` against a new organization adds a profile (the first login becomes the primary); `dws profile list` shows logged-in orgs with primary / current markers, status and validity; `dws profile switch <name|corpId|->` persistently switches the default org (`-` toggles back to the previous one, no-arg opens a TUI selector on a terminal); `dws profile use` is an alias of `switch`. `dws auth status [--profile <name>]` reports a specific profile. Credentials are stored per organization in keychain slots keyed by corpId (`auth-token:<corpId>`), with a plaintext `profiles.json` registry holding only metadata and the primary/current/previous pointers (no tokens).
- **Global `--profile <name|corpId>` flag** — run a single command against a specific organization without changing the default (one-shot; does not move currentProfile). Cross-org reads are orchestrated by the agent (list profiles → query each with `--profile` → merge); there is intentionally no built-in `--all-orgs`.
- **Backward / forward compatibility with the legacy single token slot** — a pre-existing single-slot token is migrated into `auth-token:<corpId>` and marked primary on first multi-profile use; the current (or primary) profile's token is mirrored back into the legacy slot so older binaries and the embedded host keep working. `profiles.json` is additive and ignored by older versions.
- **`dingtalk-profile` and `dws-shared` skills + multi-org documentation** (`skills/`) — a standalone `dingtalk-profile` skill plus a new `dws-shared` skill that carries auth, global flags and the multi-org rule, so every multi-mode product skill's PREREQUISITE resolves and all read/search skills inherit cross-org behavior. The mono skill gains a "multi-org / profile" section, trigger conditions, a decision-tree entry and a corrected logout danger note. Multi-mode install now always ships `dws-shared` even when `--skill` / `--exclude` narrows the set.
### Changed
- **`--ai-tag` now defaults on — DingTalk 「通过AI发送」 badge for dws-sent messages** (`internal/helpers/chat.go`, #524) — `chat message send` / `reply` flip the `--ai-tag` default from false to true, attaching the AI `clawType` by default so messages sent through `dws` (and by AI agents) transparently carry the 「通过AI发送」 badge; pass `--ai-tag=false` to send as the user with no badge.
- **Concurrency-safe, self-healing `profiles.json`** (`internal/auth/profiles.go`, `internal/auth/token.go`) — every read-modify-write on `profiles.json` and the legacy mirror is serialized under the existing dual-layer (process + cross-process) lock, split into public (locking) entry points and lock-free `*Locked` variants so the non-reentrant lock is never re-acquired (the refresh path and the load-path migration use the lock-free savers). `profiles.json` and the token marker are written via per-write random temp names + atomic rename so concurrent writers can no longer corrupt a fixed `.tmp`. An unparseable `profiles.json` is quarantined (`*.corrupt-*`) and rebuilt empty so the CLI self-heals; `auth reset` / `logout` proceed even when it cannot be read and sweep the quarantined files.
### Fixed
- **No silent fallback to a different org's token** (`internal/auth/token.go`) — when the resolved current/primary profile's keychain slot fails to read and no `--profile` was given, the loader now only falls back to the legacy single slot if it belongs to the same organization; otherwise it surfaces the error instead of acting as a different org.
- **Legacy mirror no longer wiped on a transient keychain read error** (`internal/auth/profiles.go`) — `SyncLegacyTokenMirror` distinguishes "token genuinely absent" from "keychain momentarily unreadable" and keeps the existing mirror in the latter case, so a host app's login state is not dropped by a transient failure.
## [1.0.44] - 2026-06-28
This release hardens the dynamic-command surface and finishes the dws-wukong parity pass for structured input. Phantom override commands whose backing MCP tool isn't deployed are hidden from `--help`; `report entry submit` reads `--contents-file` / stdin natively; structured JSON flags accept `@file` / `@-`; and `sheet range update` / `range read` now accept the same plain shapes wukong does (scalar cells, flat `values`, null-clears-cell, a `--hyperlinks` flag). On the wukong01 sandbox this lifts the full open-edition cli_to_mcp pass rate from 77.6% to 95.5% (sheet 28.5% → 99.8%, report → 100%); the remaining failures are account / org / out-of-scope, not CLI defects.
### Added
- **`dingtalk-dev` skill: image-upload → `mediaId` recipe + per-resource command discovery** (`skills/multi/dingtalk-dev/references/`) — documents how to obtain a `mediaId` for app / robot icons via the DingTalk OpenAPI (`credentials get` → `gettoken` → `/media/upload?type=image` → `--icon-media-id` → read back), since the dev command set has no upload command; and adds a "discovering commands" block to all 10 product refs pointing at each group's `--help` and `dws schema dev.app.<group>.<method>` (`dws schema dev.connect` for connect), so agents inspect commands instead of relying on memory.
- **`report entry submit --contents-file <path>` / `--contents -` (stdin) read natively** (#514, `internal/compat/report_hooks.go`) — the envelope publishes `entry submit` (MCP `create_report`) with a `--contents` (json_parse, required) flag plus a sibling `--contents-file` that had no transform / mapsTo, so a `--contents-file`-only submit silently sent `contents: [null]` and the report failed (only inline `--contents` worked, which is why `report create` succeeded while `report entry submit --contents-file` did not). A build-time compat hook now resolves the file / stdin natively (10MB cap, UTF-8 check, wukong priority `--contents-file` > `--contents -` > inline) and relaxes the individual `required` on `--contents` into a `contents` / `contents-file` one-of group. No discovery-config change needed.
- **`@file` / `@-` input for structured JSON flags** (`internal/compat/transform.go`) — `json_parse` / `json_parse_strict` now expand a leading `@` before parsing (`@-` reads stdin, `@<path>` reads a file), so long / complex payloads (many records, big 2D cell ranges, filter criteria) skip shell-quoting hell. A JSON / YAML value never starts with `@`, so the sentinel is unambiguous; the error hint that already advertised `@path/to/file.json` is now truthful. `sheet`'s shared `sheetParseJSONFlag` routes through `cli.ResolveInputSource` so the same support reaches `--values` / `--criteria` / `--sort-keys`.
- **`sheet range update --hyperlinks`** (`internal/helpers/sheet.go`) — a wukong-shaped 2D hyperlink grid (`[[{"type":"path","link":"...","text":"..."}]]`) overlaid onto the cells grid as each cell's `hyperlink` field; `--values` or `--hyperlinks` is now required (at least one).
### Changed
- **Phantom override commands hidden from `--help`** (#515, `internal/compat/dynamic_commands.go`) — override leaves whose backing MCP tool isn't actually deployed used to render in `dws <svc> --help` and then fail at invocation with *tool not found*. A tool-existence guard now hides them, and command groups left empty by the hidden leaves are collapsed, so `--help` reflects only invokable commands. Skill references are re-aligned to the real CLI surface (phantom commands dropped; role/duty "who is responsible" queries routed to `aisearch`, not `contact`).
- **`sheet range update` accepts scalar cells; `sheet range read` projects a flat `values`; `--values '[[null]]'` clears a cell** (`internal/helpers/sheet.go`, `internal/helpers/sheet_cell_validation.go`) — dws-wukong parity. `range update` (set_cell_range) auto-wraps a scalar cell (string / number / bool) into `{type:text,text:"..."}` instead of rejecting it, so the plain `[["姓名","部门"]]` shape that `sheet append` and wukong's update_range accept now works; a null cell clears content (matching wukong); `{}` still means keep-original. `range read` (get_cell_infos) now also exposes a flat `values` 2D array next to the rich `cells` payload, matching wukong's get_range shape without dropping cell styles.
- **report skill aligned to `entry submit` / `inbox list` / `outbox list`** (`skills/multi/dingtalk-report/`, `skills/mono/references/intent-guide.md`) — the multi skill tree was two versions behind and still taught the deprecated flat aliases (`report create` / `sent` / `list` / `detail` / `stats`) and falsely claimed `report inbox` was unimplemented. Re-aligned to the canonical resource.verb commands consistently (old aliases still execute with a stderr deprecation notice).
## [1.0.43] - 2026-06-26
This release aligns the open edition's CLI surface with **dws-wukong** across the communication domain (chat / mail / minutes / todo / calendar / contact / aisearch / live / report / ding) and the structured-office domain (aitable / sheet / drive / wiki / doc), and switches the discovery version code from `bamboo` to `cedar` so the aligned command tree is served from its own discovery config.
### Added
- **`calendar book get|search` and `calendar acl list`** (cedar discovery overrides) — query a specific calendar (primary via `--id primary`), fuzzy-search calendars by name, and list a calendar's access-control entries. Maps to the calendar MCP `get_calendar` / `search_calendar` / `list_acls` tools.
- **`calendar attendee list|add|delete`** (`internal/helpers/calendar_commands.go`) — manage event participants under the wukong-aligned `attendee` naming (equivalent to the legacy `participant` group; calls `get/add/remove_calendar_participant`).
- **`minutes tag list` and `minutes tag query --tag-id`** — list a user's AI-minutes tags and query minutes by tag (`query_user_tag_list` / `query_minutes_by_tag_id`).
- **`minutes list mine|shared|all`** (`internal/helpers/minutes_commands.go`) — list own / shared / all minutes with renamed output fields.
- **`mail folder create|update|delete`, `mail template create|list|get|update|delete`, `mail contact create|list|update|batch-delete`, and `mail message list`** — full mail folder / message-template / contact CRUD plus folder-scoped message listing.
- **`chat file upload`** (`internal/helpers/chat_file.go`) — upload a local file (init/PUT/commit) or a remote URL to a conversation's file space.
- **`todo task add-attachment`** (`internal/helpers/todo_commands.go`) — attach a local file to a todo (multi-step upload).
- **aitable extensions** (`internal/helpers/aitable_extra.go`) — advanced permission / roles, view sub-commands (lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar), section node management, workflow enable/disable, record `upsert` / `share-url` / `history-list` / primary-doc, and field search-options. Helper tools route to the hardcoded `aitable-helper` supplement endpoint.
- **sheet, drive, wiki, doc helper coverage** synced from dws-wukong (`internal/helpers/sheet.go`, `drive.go`, `wiki.go`, `doc.go`).
### Changed
- **Discovery version code `bamboo` → `cedar`** (`internal/market/registry.go`; `discoveryAPIPath = "/cli/discovery/apis/cedar"`) — version codes step by first letter (bamboo → cedar → …); `cedar` carries the dws-wukong alignment. Older binaries keep reading `bamboo`, so the change is isolated to this release line. All test/mock/generator fixtures updated to the cedar path.
- **CLI output envelope aligned with wukong for cross-edition parity** (`internal/app/runner.go`, `internal/compat/registry.go`) — dry-run prints a `DRY-RUN Arguments:` line, successful results carry `success: true`, missing-required-flag wording is unified to `missing required flag(s): --x`, and OutputTransform applies to the response content layer.
- **New flag transforms** (`internal/compat/transform.go`) — `parse_bool` (explicit boolean strings so `--flag false` is honoured) and `attendance_class_check_time` (`HH:mm` → UTC+8 milliseconds for shift check-times).
- **`--calendar-id` accepted on calendar event / participant / room / attachment commands** so calendars other than the primary can be targeted.
### Fixed
- **Client-side validation** for calendar recurrence completeness and attendance schedule / class / group inputs, surfacing input errors before they reach the server.
## [1.0.42] - 2026-06-25
This release rounds out `dws dev connect` — bridge a DingTalk robot to your local AI (Claude Code / Codex / opencode / Qoder / …): a generic `custom` channel for any headless CLI tool, in-chat `/new` / `/clear` session commands aligned to each agent's real session op, and a fix for long opencode turns being cut at 30 seconds.
### Added
- **`dws devapp robot connect` — generic `custom` channel for self-built / unsupported AI tools** (issue #37; `internal/helpers/devapp_connect.go`, `internal/helpers/connect_stream.go`) — a new `--agent-cmd "<command>"` flag (and `custom` channel) lets the bot forward to any headless AI CLI that takes a question as its trailing argument and prints the answer to stdout, so tools that aren't built-in (e.g. 网易有道龙虾 LobsterAI) or self-built agents can be onboarded without code changes. `--agent-cmd` forces the `custom` channel unless `--channel` is set explicitly; detection also falls back to `custom` when `DWS_AGENT_CMD` is present.
### Changed
- **`robot connect` now hints how to match terminal answer quality** (issue #39; `internal/helpers/devapp_connect.go`) — when neither a work dir nor a knowledge source is configured, the connector prints a one-time note that the bot runs in a clean temp dir without local project context, pointing at `--agent-workdir` / `--knowledge-dir` / `--knowledge-source` / `--agent-model`. The robot quickstart gains matching FAQ entries, plus a clarification that step 3 (`robot connect`) produces no approval ticket (issue #19).
- **`robot connect` session commands `/new` vs `/clear` now use each channel's real session op** (PR #20; `internal/helpers/connect_opencode.go`, `internal/helpers/connect_stream.go`) — `/new` (and `/start`, `/reset`) opens a fresh session and leaves the previous one intact (resumable where the agent supports it); `/clear` actively disposes the current session through the agent's real delete primitive — opencode issues `DELETE /session/:id`. Channels whose agent exposes no delete in the mode DWS drives it (Codex app-server, Qoder stream, Claude-family exec) fall back to a reset, so `/clear` behaves like `/new` there. Previously both commands only dropped the local `conversationId → sessionId` mapping, so the two were indistinguishable and opencode sessions were never disposed (they leaked).
### Fixed
- **`robot connect` no longer aborts long opencode turns at 30 seconds** (PR #19; `internal/helpers/connect_opencode.go`) — the shared opencode HTTP client hard-coded a 30s `Timeout` that covered every request, including `POST /session/{id}/message`, so a long agent turn (e.g. a multi-minute research report) was killed mid-flight with `context deadline exceeded (Client.Timeout exceeded while awaiting headers)` even though the per-turn budget (`DWS_AGENT_TIMEOUT_MS`, default 300s) was far larger. The client-level deadline is removed so the per-request ctx governs the round-trip; only the `/global/health` probe keeps a short 10s timeout so startup detection stays snappy.
## [1.0.41] - 2026-06-24
This release makes the installers work from mainland China out of the box (no env var) and keeps the Gitee mirror in sync automatically.
### Added
- **Auto-fallback to the Gitee mirror when GitHub is unreachable** (#492; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`) — the installers probe GitHub Releases on startup and, when it is unreachable (typical in mainland China), automatically resolve the version and download every asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee mirror instead. A plain `curl … | sh` now works in China with no `DWS_GITEE_REPO` needed. Explicit `DWS_GITEE_REPO` still wins, `DWS_NO_FALLBACK=1` forces GitHub, and local source-checkout installs skip the probe.
### Changed
- **CI mirrors repo code to Gitee automatically** (#493; `.github/workflows/mirror-to-gitee.yml`) — the mirror workflow now pushes `main` + tags to the Gitee mirror over HTTPS using `GITEE_TOKEN` (no SSH key), on every push to `main` and every tag, keeping the Gitee `raw/main` install scripts and tags in sync without any manual `git push`. Gated on `GITEE_TOKEN`; skips cleanly when unset.
## [1.0.40] - 2026-06-24
This release adds China-accessible install mirrors so the CLI installs reliably from mainland China, where GitHub raw + Releases are slow or fail.
### Added
- **China mirror via Gitee + npmmirror** (#486; `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-skills.sh`, `scripts/release/sync-to-gitee.sh`, `.github/workflows/release.yml`, `.github/workflows/mirror-to-gitee.yml`) — an opt-in `DWS_GITEE_REPO` env var makes all three installers resolve the latest version and every release asset (binary, `checksums.txt`, `dws-skills.zip`) from the Gitee OpenAPI v5 instead of GitHub; with it unset, installation defaults to GitHub (fully backward compatible). The release pipeline mirrors release attachments to the matching Gitee release after each tag (gated on `GITEE_TOKEN`/`GITEE_REPO`), and a hub-mirror workflow keeps the repo code in sync (gated on `GITEE_PRIVATE_KEY`). README documents three China install channels: Gitee raw script, Gitee release binaries, and the npm package via `registry.npmmirror.com`.
- **Skills embedded in the binary** (#488; `skills_embed.go`, `internal/app/skill_setup.go`, `internal/app/skill_setup_embed.go`) — the `skills/` tree (mono + multi) is embedded into the `dws` binary via `go:embed` and `dws skill setup` defaults to the embedded copy, refreshing the installed skill instead of silently reusing a stale copy probed from the current working directory — so skills install offline with no separate download.
## [1.0.39] - 2026-06-18
This release makes the AI-sent indicator opt-in. 1.0.38 unconditionally tagged every user-identity send/reply with the edition claw identity, so the IM server rendered a "Send from AI" badge under every message — and on the open edition a stale hardcoded value even leaked the Wukong-branded label (「悟空AI发送」) to external users. The badge is now off by default and shown only when the caller explicitly asks for it.
### Added
- **`--ai-tag` opt-in flag for `chat message send` / `chat message reply`** (#477; `internal/helpers/chat.go`) — by default no `clawType` tool argument is attached, so delivered messages carry no "Send from AI" badge. Passing `--ai-tag` attaches `edition.ClawType()` so the IM server renders the badge (open edition `openClaw` → 「通过AI发送」; the wukong overlay sets its own value → 「悟空AI发送」). Covers the text/Markdown, rich-media, and `--user`/`--open-dingtalk-id` direct send paths plus `reply`. Bot (`send-by-bot`) and webhook sends are intentionally untouched — they already render as bot messages. The badge is opt-in so dws does not brand every message a user sends.
### Fixed
- **`dws chat message reply` no longer leaks the Wukong AI label on the open edition** (#475, fixes #474; `internal/helpers/chat.go`, `pkg/edition/edition.go`) — the reply path hardcoded `clawType: "wukong"`, so open-source quoted replies were tagged 「悟空AI发送」 by the IM server, leaking Wukong branding to external users (reported by an external customer integrating via openclaw). The value now derives from the edition via the new `edition.ClawType()` accessor (open → `DefaultOSSClawType` = `openClaw`), and — together with #477 — is only attached when `--ai-tag` is passed. The earlier fix existed on a branch (PR #450) but was never merged to main; #475 cherry-picked it.
## [1.0.38] - 2026-06-16
This release adds client-side agent attribution for usage stats, fixes two commands that silently misbehaved (`dws sheet export` hanging, `dws upgrade --dry-run` actually upgrading), hardens the document write path against server-rejected characters, and makes the long-broken `--no-browser` login flag actually work.
### Added
- **Client-side `agent_code` detection + per-channel agent instance id for usage stats** (#467; `internal/auth/agent_code_detect.go`, `internal/auth/identity.go`, `docs/agent-code.md`) — every MCP request now carries `x-dingtalk-dws-agent-code` (which agent host is driving dws — e.g. `claudecode` / `codex` / `qoder` / `cursor` / `hermes` / `openclaw`, falling back to `custom`), `x-dws-agent-instance-id` (a per-machine×channel id, `dwsa_<base62(sha256(machineId|agent_code))>`), the existing machine-level `x-dws-agent-id`, and `X-Cli-Version`. Detection is a confidence ladder, each signature verified on real hosts / official docs (never guessed; anything unrecognized resolves to `custom`): T0 explicit `DINGTALK_DWS_AGENTCODE`, T1 per-agent env signatures, T2 `VSCODE_BRAND` covering the whole VS Code fork family, T3 the macOS `__CFBundleIdentifier` map, T4 `custom`. `identity.json` migrates v1 → v2 transparently and keeps `x-dws-agent-id` machine-level for continuity. **Trust boundary:** `agent_code` and both ids are client self-reported and forgeable — they are for stats / observability only and must not be used for auth, authorization, rate-limiting, billing, or revocation. Server-side gateway work (header passthrough allowlist + logging the fields into the warehouse) is required before the data lands and is tracked separately.
### Fixed
- **`dws sheet export` no longer hangs for the full ~5-minute poll timeout** (#462; `internal/compat/pipeline.go`) — the pipeline poll loop compared the API status against `pollUntilValue` with case-sensitive `==`, but the API returns `"success"` while the pipeline config declares `"SUCCESS"`, so the match never fired and the loop spun until timeout. Switched to `strings.EqualFold`, aligning with the case-insensitive `normalizeAsyncStatus` helper already used for `doc export` / `aitable export`.
- **`dws upgrade --dry-run` now previews instead of performing a real upgrade** (#416, fixes #364; `internal/app/upgrade.go`) — `newUpgradeCommand` registered no `--dry-run` flag and never read the global persistent one, so `--dry-run` fell through and ran a real, irreversible upgrade (download + binary replace), directly contradicting the flag's documented `预览操作内容,不实际执行` contract. It now resolves the target release and platform asset (so "already latest" / "no build for this platform" is still surfaced), prints the 1–5 steps it *would* perform via the side-effect-free `writeDryRunPlan`, and returns before any backup / download / replace. Covered by `TestWriteDryRunPlan_*` and an updated help test.
- **`dws doc create` / `dws doc update` strip server-rejected characters instead of failing** (#465; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`) — the Markdown write path sent raw content straight through, and the dangerous-Unicode strip only ran on the JSONML branch, so content carrying C0 control characters (anything `< 0x20` except `\t` / `\n`), DEL (`0x7F`), or zero-width / line-separator codepoints (`U+200D`, `U+2028`, `U+2029`) — common in LLM-generated or copy-pasted text — was rejected by the server-side `RejectControlChars` validator and the command failed. `stripDocDangerousUnicode` is renamed to `stripDocInputUnsafe`, extended to match the authoritative `apiclient.rejectDangerousChars` set, and applied on both the Markdown and JSONML node write paths. Tab and newline are preserved. Ported from dws-wukong.
- **`dws auth login --no-browser` is now honored** (#365; `internal/app/auth_command.go`, `internal/auth/device_flow.go`, `internal/auth/oauth_provider.go`) — the flag was already defined (and hidden) but never wired to the login providers, so the browser always opened regardless. The value is now passed into `DeviceFlowProvider.NoBrowser` / `OAuthProvider.NoBrowser` and gates the `openBrowser` call; the flag is also unhidden so headless / remote sessions can discover it.
## [1.0.37] - 2026-06-11
This release realigns the npm channel and hardens PAT batch grants. Background on the npm realignment: 1.0.36 was re-cut on GitHub on 2026-06-11 to fold in the canonical-tree poisoned-cache guard (#454), but the npm registry permanently forbids republishing a version number, so the npm package stayed on the original, unguarded cut. 1.0.37 is therefore the first version where **every** distribution channel — GitHub releases, `dws upgrade`, the install scripts, and npm — ships the same guarded build. If you installed 1.0.36 from npm, upgrade to this version.
### Fixed
- **PAT batch grants carry the agent identity and require explicit confirmation** (#455; `internal/pat/chmod.go`, `internal/auth/channel.go`, `internal/app/runner.go`) — an explicit `--agentCode` flag or the `DINGTALK_DWS_AGENTCODE` env var is now carried into PAT batch plan/grant arguments instead of being dropped, and a missing agentCode is forwarded as absent so the PAT core can apply the server-side default rather than failing. Batch grants now refuse to execute without an explicit `--yes` (dry-run and single-scope grants keep their existing behavior), closing the gap where a multi-scope grant could fire without a deliberate confirmation. Only the canonical env name `DINGTALK_DWS_AGENTCODE` is recognized; draft/reversed spellings from earlier iterations are ignored. Verified against prepub: dry-run, single grant, flag-priority grant, and batch grant all resolve the target agentCode, with the granted rows confirmed server-side. Tests: `internal/pat/chmod_test.go`, `internal/pat/browser_policy_test.go`, `test/unit/pat_host_owned_signal_test.go`.
## [1.0.36] - 2026-06-10
This release closes out the poisoned-discovery-cache lock-out for good, with four layers of defense landing together. The lock-out class (seen again on 2026-06-09 as `chat_permission_grant flag redefined: params`): the dynamic command tree is built from cached discovery data **before** Cobra dispatches any command, so a pflag panic fed by a poisoned cache aborted *every* invocation — including `dws cache refresh` and `dws upgrade`, the very commands that could repair it. Now: (1) any panic during the build is recovered instead of crashing (#447), (2) the four known envelope shapes that made pflag panic are skipped at registration so they never fire (#449), (3) when an unknown panic class does fire, the CLI quarantines the poisoned cache and rebuilds itself from a fresh fetch — and `dws upgrade` clears the discovery caches after every binary swap, so simply getting this version onto a machine is enough to escape, no manual cache surgery (#452), and (4) the same guards now also cover the canonical `dws mcp` tree, which is built even earlier and sat outside all three defenses as originally cut (#454 — this release was re-cut on 2026-06-11 to include it; verified against the preserved real poisoned cache from the 2026-05-25 incident). Also in this release: `dws devdoc` gains RAG-backed Open Platform doc search and a new error-diagnosis command (#434), and `dws doc create` stops producing documents with two identical titles (#448).
**Escaping a locked-out older binary**: a binary ≤1.0.35 bricked by a poisoned cache cannot run `dws upgrade`. Either bypass the cache for one invocation with `DWS_CACHE_DIR=$(mktemp -d) dws upgrade`, or delete `~/.dws/cache/<partition>/tools/` by hand, or reinstall via the install script. Once 1.0.36 is on the machine this never needs doing again.
### Added
- **`dws devdoc` — RAG-backed Open Platform doc search and error diagnosis** (#434; `internal/helpers/devdoc.go`, `internal/transport/client.go`) — `dws devdoc article search` now routes to the upstream `search_open_platform_docs_rag` tool, returning structured RAG/reference payloads (the CLI stays a thin invoker; no extra AI analysis layer). New `dws devdoc error diagnose` (alias `troubleshoot`) routes to `search_open_error_code_rag` for diagnosing DingTalk Open Platform API errors, with `--request-id` (hidden `--trace-id` kept for compatibility), `--error-code`, `--error-message`, `--api`, `--context`, `--query`, `--page`, `--size`. Transport-side: query parameters required by DingTalk MCP gateway URLs are preserved on the wire but their values are redacted from debug logs. Default MCP / skill hosts stay on production `https://mcp.dingtalk.com` (prepub remains runtime-configurable). Skill docs (mono + multi `dingtalk-devdoc`) and `docs/command-index.md` updated alongside.
### Fixed
- **CLI no longer bricks when the dynamic command build panics — degrades to built-in commands** (#447; `internal/app/legacy.go`) — `buildEnvelopeCommandsSafe` wraps the envelope-driven build in a local `recover()`. On panic the CLI logs it, prints a stderr hint, and falls back to the hardcoded helper commands, so `auth` / `cache` / `doctor` / `version` / `upgrade` and the helpers stay alive and `dws cache refresh` can rebuild the poisoned cache. Before this, the only recovery from the pre-1.0.32 lock-out class was manually deleting cache files; the duplicate-flag class itself had been fixed at the builder level, but any *future* panic class in the cache-driven build would have bricked the CLI again. Tests: `TestNewLegacyPublicCommandsPanicFallsBackToHelpers`, `TestNewLegacyPublicCommandsNoPanicKeepsDynamicPath`.
- **Envelope-driven flag registration no longer panics on the four known malformed-envelope shapes** (#449; `internal/compat/registry.go`) — while reproducing the lock-out byte-for-byte, four envelope shapes were found still forwarded to pflag calls that panic, each bricking every invocation: a flag named `params` / `json` colliding with the reserved payload flags (the original `flag redefined: params` — earlier dedup fixes covered the alias list and Detail-schema path but not the primary name); two bindings resolving to the same long flag name across bindings; two flags claiming the same shorthand; and a multi-character shorthand. Two small guards applied at every registration site (`ApplyBindings`, `registerPositionalAliasFlags`): `canRegisterFlag` skips duplicate/reserved long names (the value stays reachable via `--params`), and `safeShorthand` drops an invalid or already-taken shorthand while keeping the long flag. The trailing `--json` / `--params` registration is now idempotent. Defense in depth with #447: the escape hatch should never trigger for these known vectors. Test: `TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope` (5 table-driven vectors).
- **Poisoned discovery cache now self-heals: quarantine + rebuild on panic, and `dws upgrade` clears discovery caches** (#452; `internal/app/legacy.go`, `internal/app/upgrade.go`, `internal/cache/store.go`) — #447's recovery is upgraded from "degrade and ask the user to run `dws cache refresh`" to a two-stage self-heal: on the first build panic the partition's discovery cache is moved aside to `<partition>.quarantined` (kept on disk for inspection; a previous quarantine is replaced so nothing accumulates — new `Store.QuarantinePartition`) and the build retried once against a fresh fetch. If the retry succeeds the user gets the full dynamic command tree with zero manual steps; only a second panic (remote envelope itself still poisoned, or offline) degrades to helper commands with the `cache refresh` hint. Additionally `dws upgrade` purges discovery-derived caches (`market` / `tools` / `detail` across all partitions — new `Store.PurgeDiscoveryData`) after a successful binary swap, leaving the co-located `downloads/` cache untouched, so an upgraded binary always rebuilds its command tree from fresh data instead of inheriting snapshots written by the old version. Tests: `internal/cache/store_quarantine_test.go`, rewritten `internal/app/legacy_panic_fallback_test.go` (self-heal success, double-panic degradation, no-cache no-op, happy path).
- **Canonical `dws mcp` tree no longer escapes the poisoned-cache guards** (#454; `internal/cli/canonical.go`, `internal/app/root.go`) — the canonical tree is assembled from cached catalog data *before* the legacy command build, so a pflag panic there — a tool schema property named after the reserved `--params` flag, exactly what the 2026-05-25 incident cache contained — bypassed #447/#449/#452 entirely and still bricked every invocation, including on this release as originally cut. Two layers, mirroring the existing guards: `applyFlagSpecs` skips reserved (`--json`/`--params`), duplicate, and alias-colliding flag names and sanitizes shorthands (`canRegisterToolFlag` / `safeToolShorthand`; a skipped property stays reachable through the reserved JSON payload flags), and `newMCPCommand` wraps the build in the #452 recover → quarantine → retry-once → degrade-to-stub sequence. Verified against the preserved real poisoned cache: the original cut locks out on `--version` / `cache refresh` / `doctor`; this build self-heals on first run and `cache refresh` clears the poison. Tests: `internal/cli/canonical_flag_guard_test.go` (4 cases), `internal/app/canonical_panic_fallback_test.go` (4 cases mirroring the legacy fallback suite).
- **`dws doc create` no longer produces a document with two identical headings** (#448; `internal/helpers/doc.go`) — the platform renders the document name as the page title, and LLM agents habitually repeat `# <title>` as the markdown body's first line despite the skill docs saying not to, so duplicate-heading documents kept appearing. The `doc create` helper (which wins the envelope merge via `preferLegacyLeaf`) now strips a leading ATX H1 whose text exactly equals `--name` (trimmed, case-insensitive) before forwarding to `create_document`, printing a stderr note so agents learn the convention. Deliberately conservative: only an exact match is removed (`# 背景` stays), ATX closing hashes are handled without over-trimming names ending in `#` (e.g. `C#`), H2+/setext headings are never touched, and a body that is nothing but the duplicate H1 omits the `markdown` param instead of sending an empty string. JSONML bodies are out of scope. Tests: `TestStripLeadingDuplicateTitleHeading` (9 cases) plus three end-to-end cobra tests asserting the exact `markdown` param sent.
## [1.0.35] - 2026-06-08
### Fixed
- **`chat message send` @-mentions not rendered in group / direct chat** (#433, `internal/helpers/chat.go`) — when sending a group message or an openDingTalkId direct message (`send_personal_message`) as the current user, the `content` body was packed with `json.Marshal`, whose default HTML escaping turns the `<` `>` in `<@openDingTalkId>` / `<@all>` into `<` `>`. The DingTalk client renders @-mentions by matching the **literal** `<@...>` token, so after escaping the match fails and the mention shows as plain text — while the API still returns `success`, masking the bug. Fix: add `marshalMessageContent`, which serializes `{title,text}` with `json.Encoder` + `SetEscapeHTML(false)`; both the group and openDingTalkId-direct `send_personal_message` paths now use it, preserving the literal `<@...>`. Added regression test `TestChatMessageSendContentNotHTMLEscaped` asserting the content keeps the literal token and is never HTML-escaped. Verified on a real device: `@someone` and `@all` both render as clickable blue mentions.
- **`chat` skill docs & scripts aligned to direct-chat `list-direct`** (#424) — `chat message list` now supports group chats only (`--user` / `--open-dingtalk-id` removed); reading a direct chat moves to the dedicated `list-direct` command, but the skill docs and scripts still taught `chat message list --user`, which now errors with `unknown flag: --user`, also breaking `chat_history_with_user.py` (listed as the "preferred" way to query direct chats). This update: `skills/{mono,multi/dingtalk-chat}/references/products/chat.md` switches `message list` to group-only and documents the new `list-direct` command, syncing the intent routing / key-distinction / context-passing tables / caveats; `skills/mono/references/best_practices/01-messaging.md` changes query-private-chat from `list --user` to `list-direct` (the multi version was already updated); `chat_history_with_user.py` (mono + multi) now calls `list-direct` and fixes response parsing (unwraps `result.messages`, aligns `createTime/content/sender` fields — it previously crashed on `'str' object has no attribute 'get'`). Direct-chat sending still uses `chat message send --user` (since v1.0.34 the direct-send rpc is folded into the `send` command; there is no separate `send-direct`). Docs/scripts only; no change to CLI binary behavior.
- **`pat chmod` batch authorization did not pass through `agentCode`** (#414, `internal/pat/chmod.go`) — the batch plan / grant paths (`buildBatchPlanArgs` / `batchArgs`) previously carried `agentCode` only in the single-grant `toolArgs`; batch calls omitted it, so a batch authorization with an explicit `agentCode` was processed under the default agent. Fix: the batch plan / grant args now also carry `agentCode`, matching the single-grant path.
- **`pat` JSON output escaped the authorization URL into an unreadable form** (#401, `internal/pat`) — the authorization URL attached to PAT error messages, after default HTML escaping, turned `&` into `&`, breaking the link when copied / recognized on mobile. Fix: the PAT error-enrichment JSON output now uses `SetEscapeHTML(false)` (scoped to PAT JSON only), preserving the readable `&` separators.
## [1.0.34] - 2026-06-03
### Changed
- **Service discovery path now carries a version-coded segment** (`internal/market/registry.go`) — the server-list endpoint moves from `/cli/discovery/apis` to `/cli/discovery/apis/bamboo`. The path is now a single `discoveryAPIPath` constant 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. Discovery via the edition `DiscoveryURL` hook (full-URL `FetchServersFromURL`) is unaffected. Server side must serve the new path.
### Removed
- **`dws aiapp` — AI application product taken offline** — removed the `aiapp` product surface (`create` / `query` / `modify`) from the CLI: deleted `internal/helpers/aiapp.go`, dropped it from the generator coverage targets and `knownRegistryProducts`, removed the `aiapp` skill references (mono `references/products/aiapp.md` + `dingtalk-aiapp` multi skill), and unpublished the `aiapp` server from the service-discovery envelope. Product count drops from 19 to 18.
## [1.0.33] - 2026-06-02
This release merges the multi-contributor `pre-mcp-discovery` feature branch into `main` as a single squash (#391), bringing a large batch of new product surface — full DingTalk **docs** (`doc`), **knowledge base** (`wiki`), **AI app** (`aiapp`), AI-table **forms** + **import/export**, and reworked **mail** / **todo** / **report** command trees — while keeping service discovery pinned to production `https://mcp.dingtalk.com` (the branch's `pre-mcp.dingtalk.com` endpoint change was deliberately excluded; the four host constants in `skill_command.go` / `auth/endpoints.go` / `cli/loader.go` / `market/registry.go` stay on prod). It also folds in the portable auth bundle (`dws auth export` / `import`, #357) and PAT batch authorization (#389).
### Added
- **`dws doc` — full DingTalk document command family** (#387, #362, #388, #390; `internal/helpers/doc.go`, `internal/helpers/doc_jsonml.go`, `internal/helpers/docjsonml/`) — search / list / info / read / create / update / upload / download / copy / move / rename, plus `file`, `folder`, `block`-level editing and `comment` (list / create / reply / create-inline). Authoring supports both DocxXML and a JSONML format with a v2 schema validator (`docjsonml/jsonml-schema-v2.json` + `doc_jsonml_validate_v2.go`). Document export and OA alignment land here.
- **`dws wiki` — knowledge base management** (`internal/helpers/wiki.go`, `internal/helpers/wiki_proxy.go`) — knowledge space `create` / `get` / `list` / `search` and member `add` / `list` / `update`, routed through a wiki proxy server.
- **`dws aiapp` — AI application lifecycle** (`internal/helpers/aiapp.go`) — `create` (with prompt / attachments / skills), `query` by task ID, `modify` by thread ID.
- **`dws aitable` forms + import/export** (`internal/helpers/aitable_form.go`, `internal/helpers/aitable_export_import.go`) — datasheet form management and full record import/export, the latter driven through the async-task helper for large datasets.
- **Reworked `chat` / `report` / `todo` / `contact` / `mail` command trees aligned to the Wukong baseline** (#355; `internal/compat/mail_hooks.go`, `internal/compat/todo_hooks.go`, `internal/helpers/report_readable.go`) — mail and todo gain dedicated compat hooks; `report` gains a human-readable rendering path alongside the raw JSON, plus deprecation shims for the old report shape.
- **`dws auth export` / `dws auth import`** (#357) — portable auth bundle for migrating Linux sandbox credentials. Exports the encrypted keychain (`~/.local/share/dws-cli`, including `auth-token.enc` and `dek`) plus required `~/.dws` config so refresh tokens survive import; copying only `app.json` leaves access tokens expiring after ~2 hours. Supports `-o` / `-i` tar.gz paths and `--base64` for copy/paste between sandboxes. `dws auth status` now shows refresh-token validity in table output.
- **Async-task and paging infrastructure** (`pkg/asynctask/`, `pkg/paging/`) — shared helpers underpinning long-running operations (e.g. aitable import/export, doc export) and cursor/page traversal.
### Changed
- **`envelope` now registers `cli.Aliases` as cobra aliases** (#391) — discovery-generated commands expose their declared aliases natively in the command tree, with accompanying command-structure and JSON-parsing cleanups.
- **Breaking: `dws pat chmod` prints a compact authorization summary by default, and gains batch authorization flows** (#389; `internal/pat/chmod.go`) — scripts that parse the raw MCP JSON from stdout must now pass `--format json` or `--verbose` to keep the machine-readable payload; the default summary keeps grant status, agentCode, grantType, scope counts, and a next-action hint. New batch grant/plan flows (`pat.batch_grant` / `pat.batch_plan`) authorize multiple products in one session, fall back to the legacy single-grant path when the server reports `PAT_BATCH_AUTH_UNSUPPORTED`, use the server's default `agentCode` when none is given, and surface per-tool authorization metadata for grant planning.
- **Skill packs synced to the Wukong-aligned content** across attendance / calendar / minutes / oa / sheet and others (#391).
## [1.0.32] - 2026-05-25
Two user-visible regressions resolved plus two AI-agent discoverability fixes. `dws drive upload` was returning `HTTP 403 SignatureDoesNotMatch` for any file whose MIME detects to a non-empty value — basically every real file — because the helper added a client-side `Content-Type` fallback whenever `drive.get_upload_info` returned an empty headers map. DingTalk drive's OSS presigned PUT URLs are signed against an empty `Content-Type` at signing time, so any client-supplied header makes the signature OSS recomputes diverge from the server-signed one, and the PUT is rejected (#347). On Apple Silicon, `dws upgrade` was aborting at the "解压并验证" step with `signal: killed` because GoReleaser cross-compiles `darwin/arm64` binaries on `ubuntu-latest` with no codesign step, and macOS 11+ `amfid` SIGKILLs unsigned arm64 binaries on first exec (#339) — the release pipeline now ad-hoc signs every darwin tarball, and the upgrade client self-heals if it ever encounters an unsigned binary again. On the AI-agent discoverability side, `dws aitable attachment upload-file` (the one-shot prepare + PUT + commit composite) is no longer hidden from `--help` — agents that only browse the command tree were getting stuck at the prepare-only `attachment upload` step, which returns an upload URL + fileToken but doesn't actually upload. And `dws --help` itself now surfaces the missing-command upgrade hint that the custom `renderRootHelp` had been silently dropping from cobra's `root.Long`.
### Added
- **`dws aitable attachment upload-file` is now visible in `dws aitable attachment --help`** (#347, `internal/helpers/aitable.go`) — the hardcoded one-shot composite (prepare + HTTP PUT + commit, returns `fileToken` directly) was previously marked `Hidden:true` and only reachable by agents that read `skills/references/products/aitable.md`. Agents that only discover commands via `--help` were getting stuck at the sibling envelope-generated `attachment upload` (prepare-only): they'd receive `uploadUrl` + `fileToken`, have no idea how to consume the URL, and either write the URL into the attachment field as if it were a token (wrong shape — the field expects `[{"fileToken":"ft_xxx"}]`) or fall back to "please use the UI" messages, which made `dws` look broken even though the capability was fully implemented. Unhiding mirrors the discoverability pattern `lark-cli base +record-upload-attachment` already follows. `Short` is tightened to explicitly mention the 3 steps it bundles; `Long` calls out the prepare-only sibling and recommends `upload-file` as the default for AI agents. The sibling `attachment upload` (prepare-only) keeps its envelope-generated registration but gets a new `Long` that states it is only step 1 of a 3-step flow, lists what an agent must do after (HTTP PUT to `uploadUrl`, then write `[{"fileToken":"ft_xxx"}]` into the attachment field), and points to `upload-file` as the recommended one-shot alternative. `TestAITableUploadFileCommandIsDiscoverable` in `internal/helpers/aitable_upload_file_test.go` guards against re-introducing `Hidden:true`.
- **`dws --help` root output now surfaces the `dws upgrade` hint when no listed command fits** (#347, `internal/app/root.go` + `internal/app/root_help.go`) — `root.Long` is set to `"提示: 如果遇到能力缺失、命令报错、新功能未注册、或无法完成任务, 请先用 'dws upgrade' 升级到最新版本后再试. 钉钉 OpenAPI 和 dws CLI 持续迭代, 新能力和 bugfix 会先在新版本上线."`. The custom `renderRootHelp` (which replaces cobra's default template to render the services / utilities sections) had been silently dropping `root.Long`; restoring it costs one `Fprintln` after the command list, separated by a blank line. The natural failure mode for both agents and users staring at `dws --help` is to give up or hack around when none of the listed commands fit — but in many cases the right action is simply `dws upgrade`, because new capabilities and bugfixes ship continuously and a missing command is usually a stale-binary issue. `TestRenderRootHelpIncludesLong` in `internal/app/visibility_test.go` uses a sentinel `Long` string and asserts the rendered output contains it verbatim, so any future rewrite of the help renderer that drops `Long` fails this test immediately.
### Fixed
- **`dws drive upload` no longer fails with `HTTP 403 SignatureDoesNotMatch` on any non-empty MIME type** (#347, `internal/helpers/drive.go`) — `httpPutDriveFile` was setting `req.Header["Content-Type"] = fallbackMIME` whenever the prepare_upload response returned an empty headers map. DingTalk drive's OSS presigned URLs sign `StringToSign` against an empty `Content-Type` at signing time, so any client-side header makes the signature OSS recomputes at PUT time differ from the server's presignature, and the upload is rejected with `403 SignatureDoesNotMatch`. This broke every `dws drive upload` for any file whose MIME detects to a non-empty value (`image/png`, `application/pdf`, every common binary) — i.e. essentially every real upload. Fix: drop the `hasContentType` / `fallbackMIME` path entirely, trust the server's headers map as authoritative; empty map means "no client-side headers needed", do not infer. `httpPutDriveFile`'s signature loses the `fallbackMIME` parameter. Manual verification: `curl -X PUT -H "Content-Type:" --data-binary @file <same-presigned-url>` returns `HTTP 200`, proving the only difference was the client-side `Content-Type`. `TestHttpPutDriveFile_NoContentTypeWhenServerHeadersEmpty` guards the empty-map path; `TestHttpPutDriveFile_PassthroughServerHeaders` guards that server-provided `Content-Type` / `x-oss-*` headers are forwarded verbatim. Important: `internal/helpers/aitable.go`'s `upload-file` helper deliberately keeps its `Set("Content-Type", mimeType)` call — its OSS endpoint uses a different signing mode (server includes the client-declared MIME in the signature, verified across 12 file types — all succeed). The two helpers must not be unified without re-validating both endpoints.
- **`dws upgrade` no longer dies with `signal: killed` on Apple Silicon after fetching the new binary** (#339) — GoReleaser cross-compiles `darwin/arm64` binaries on `ubuntu-latest` with no codesign step, and macOS 11+ on Apple Silicon requires at least an ad-hoc signature on every arm64 binary; `amfid` SIGKILLs unsigned arm64 binaries on first exec, which the upgrade client surfaces as `signal: killed` and aborts at the "解压并验证" step. Two layers of fix:
- **Release-side ad-hoc signing** (`scripts/release/post-goreleaser.sh` + `.github/workflows/release.yml`) — after GoReleaser produces the per-platform tarballs, `post-goreleaser.sh` unpacks each `dws-darwin-*.tar.gz`, applies an ad-hoc signature (`codesign --force --sign -` locally, `rcodesign` in CI), deterministically repacks the tarball, and rewrites the matching line in `checksums.txt` so the checksum stays consistent with the resigned tarball. `release.yml` installs `rcodesign 0.27.0` before GoReleaser runs. Every 1.0.32+ tarball ships signed; the install regression is fixed at the source.
- **Client-side self-heal in `validateNewBinary`** (`internal/app/upgrade.go`) — when running the freshly-extracted binary returns `signal: killed` on darwin, the validator retries once after running `codesign --force --sign -` on the binary and clearing the `com.apple.quarantine` xattr. This keeps `dws upgrade` working even if a future release ever skips the signing step again, and covers users upgrading from older unsigned binaries. `internal/app/upgrade_test.go` (+80 lines) covers the retry path end-to-end: a stripped binary exits 137 on first exec, `validateNewBinary` recovers via ad-hoc sign + xattr clear, the final binary shows `Signature=adhoc` and runs.
## [1.0.31] - 2026-05-21
Closes the last drive-surface gap with the Wukong edition: `dws drive upload` lands as a single-shot composite (`drive.get_upload_info` → HTTP PUT to OSS → `drive.commit_upload`) so a local file reaches DingTalk drive in one CLI invocation, no manual three-step orchestration. Two more drive commands — `dws drive list-spaces` (list visible drive spaces) and `dws drive delete` (delete a drive file, routed via `serverOverride` to the doc MCP server) — ship via the portal envelope; `dws cache refresh` once to pick them up. Companion skill docs teach the agent to recognise dingpan URLs of the form `alidocs.dingtalk.com/document/edit?dentryKey=…` / `…/document/preview?dentryKey=…` and pass the whole URL through to `--node` instead of trying to extract `dentryKey` by hand (the server interprets `dentryKey` and a bare `nodeId` differently — manual extraction was failing).
### Added
- **`dws drive upload --file <path> [--folder <dentryUuid>] [--space-id <id>] [--file-name <name>] [--mime-type <type>]`** (#335, see `internal/helpers/drive.go`) — composite leaf that runs the full three-step upload internally:
1. `drive.get_upload_info` — fetch the OSS-signed `resourceUrl` + `uploadId` + per-URL headers.
2. HTTP `PUT` the file binary to OSS (10-minute timeout, attaches every header returned by step 1).
3. `drive.commit_upload` — register the new file under the target space / folder.
`--dry-run` prints the three step invocations as a single JSON payload without making any network calls. `--file -` is rejected on purpose: this is a local-path upload, not stdin streaming. `--folder` only accepts a `dentryUuid`; pure-numeric values are rejected up front (`validateDriveParentID`) so callers don't accidentally pass a chat-link `dentryId` (a different ID namespace) where the drive API expects a `dentryUuid`. Response normalisation handles all the wrapper shapes the upstream returns — `content` / `result` envelopes, `resourceUrls[]` arrays, and the flat `resourceUrl` / `uploadUrl` fallbacks — so the composite produces a stable JSON shape regardless of which path the upstream takes. The helper only registers `upload`; the existing six envelope-generated leaves (`list` / `info` / `download` / `mkdir` / `upload-info` / `commit`) keep flowing through dynamic discovery unchanged. `pickCommands.MergeHardcodedLeaves` guarantees dynamic leaves win on collision, so this helper only fills the upload gap.
- **`dws drive list-spaces` and `dws drive delete` (envelope rollout)** (#335, ships via portal envelope) — `list_spaces` registers as a plain `cliName` alias on the existing drive MCP server; `delete_document` registers with `serverOverride: doc` so the call routes to the doc MCP server (which owns the delete API), surfacing under the drive command tree for ergonomics. **Existing users must run `dws cache refresh` once** to pick up these two new leaves; no binary upgrade is required for them, but they pair naturally with the v1.0.31 client that ships `upload`.
- **`skills/references/url-patterns.md`** (#335) — single authority for dispatching `alidocs.dingtalk.com` URLs across doc / sheet / wiki. Five-way split: `/i/p/<token>` short links → expand via `doc info`; `/i/nodes/<id>` node URLs → probe with `doc info` and route by `contentType` / `extension` / `nodeType`; `/spreadsheetv2/...` → `sheet`; `/document/edit|preview?dentryKey=<key>` (dingpan format) → pass the whole URL to `--node`, do not strip `dentryKey` by hand; `/i/share/...` (read-only share) → use the `read_url` fallback. The "URL precheck" Step 0 in `skills/SKILL.md` now redirects every URL-bearing prompt through this dispatcher before the agent picks a product.
### Changed
- **`skills/references/products/doc.md` — `--node` accepts dingpan URLs end-to-end** (#335) — `dws doc info` / `dws doc read` examples gain two extra rows showing `--node "https://alidocs.dingtalk.com/document/edit?dentryKey=<KEY>"` and `…/preview?dentryKey=<KEY>` as first-class `--node` inputs. The "URL recognition & DOC_ID extraction" table adds the `document/edit|preview?dentryKey=<key>` row, and the extraction rules are split into three explicit clauses so the agent stops manually pulling `dentryKey` out of the URL and feeding it as a bare `nodeId` (which the server rejects). The "nodeId dual-format note" upgrades to "nodeId multi-format note" with four equivalent `--node` input shapes side by side.
## [1.0.30] - 2026-05-19
Aligns the open-source CLI with the IM envelope and schema-pipeline plumbing the Wukong edition has been running in pre-prod, plus three user-visible quality-of-life fixes. The most visible one: chat-bot webhook payloads carrying literal Chinese mentions (`@所有人 周报来了` / `@张三 看一下`) no longer fail with `file not found` — `@` is only treated as the `@<filename>` file-injection prefix when followed by an ASCII path-shaped character. The `chat` command tree is refactored to lean on the service-discovery envelope: thin wrappers (`chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search`) move out of the hardcoded helper and become envelope-generated dynamic commands; the helper keeps only the chat commands with real business logic (intelligent routing, current-user resolution, response normalization, stdin/@file input). A new `dws chat message reply` joins the existing `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` family. Underneath: `transform: invert_bool` lets envelopes flip boolean semantics between CLI surface and MCP body (e.g. `--off` ↔ `mute=true`); the pipeline executor fail-fast on upstream `content.errorCode` instead of polling forever; service-discovery dedup keeps two envelope entries that share an MCP endpoint but declare different `cli.id` as separate descriptors (so the `bot-root` / `bot-message` / `bot-group` trio fronting one MCP server stays as three distinct CLI command roots); and `dws chat` no longer nests as `dws chat chat` when two envelope servers both declare the same top-level command name.
### Added
- **`transform: invert_bool` for envelope flag overrides** (#317, see `internal/compat/transform.go`) — flips a boolean at send time. Strings `true`/`1`/`yes`/`on` → `false`; `false`/`0`/`no`/`off`/`""` → `true`. Used when the CLI surface and the MCP body have opposite semantics — e.g. envelope declares `--off` on the CLI but the MCP parameter is `mute=true` for "muted". The framework flips at send time so the envelope keeps the natural CLI verb without forcing every caller to remember the inverted mapping. Coverage in `internal/compat/transform_test.go`.
- **`dws chat message reply`** (#317, see `internal/helpers/chat.go`) — reply to a chat message. Sits alongside `send` / `send-by-bot` / `recall-by-bot` / `send-by-webhook` under `dws chat message`.
### Changed
- **`chat` command tree refactored to lean on the service-discovery envelope** (#317, commit `6be1247`) — `internal/helpers/chat.go` now only carries the chat commands that need real business logic on top of the raw MCP call: `chat message send` (current-user resolution + symmetric direct/group title validation), `chat message send-by-bot` / `recall-by-bot` / `send-by-webhook` (bot routing + stdin/@file input), and `chat group create` (response normalization). The thin wrappers — `chat search`, `chat group rename`, `chat group members list/add/remove/add-bot`, `chat bot search` — are now produced by the envelope as dynamic commands. Net diff in the helper: `+358 / -71` overall (re-aligning to envelope-owned chat structure), and `chat_test.go` drops 71 lines of test-stubs the dynamic path covers natively. Every previously documented chat command keeps the same flag set and the same MCP tool routing — the surface is just sourced differently.
- **Pipeline executor fail-fast on `content.errorCode`** (#317, see `internal/compat/pipeline.go`) — when an upstream tool returns a non-empty `content.errorCode`, `executePipelineCall` raises a validation error immediately with the upstream `errorMessage` instead of proceeding into the poll/download phase. Pre-execution cobra validation (`MarkFlagRequired`) only checks that a flag was set, not that its value was non-empty — so a `--required-flag ""` reaches the upstream tool and the upstream rejects with `errorCode`. Without the short-circuit the pipeline kept polling for a task ID that would never exist, either spinning to `PollTimeout` or burning through retries with no actionable error. Exit code 2 (validation), same as any other CLI-layer pre-flight rejection.
- **Service-discovery dedup keys now include `cli.id`** (#317, see `internal/market/registry.go`) — `NormalizeServers` used to dedup envelope entries by endpoint alone (and by `displayName` in the second pass), which collapsed envelope entries that intentionally split one MCP endpoint into multiple CLI command trees. The `bot-root` / `bot-message` / `bot-group` trio all front the same `.../server/4717...` MCP endpoint and share the displayName `机器人消息`, but each declares a distinct `cli.id` and a distinct CLI command root; the old dedup kept only the last-write and dropped two of them. The dedup key now appends `#<cli.id>` when present, falling back to endpoint / name when absent so historical envelopes without `cli.id` keep their existing behaviour. Coverage in `internal/market/registry_test.go`.
### Fixed
- **`@<text>` injection no longer eats Chinese mentions like `@所有人` / `@张三`** (#317, see `internal/cli/stdin.go`) — `ReadFileArg` and `ResolveInputSource` used to treat *any* value starting with `@` as the `@<filename>` injection syntax. Chat-bot webhook payloads commonly contain literal mentions, so `dws chat message send-by-bot --text "@所有人 周报"` was failing with `file not found: 所有人 周报` before the message reached the API. The new `looksLikeFilePath` heuristic accepts `@` followed by an ASCII path-prefix character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin, and passes the value through unchanged otherwise. `@A 但接下来都是中文@测试` *does* still attempt a file lookup because the rune right after `@` is ASCII — this matches the documented `@<path>` prefix shape. The historical "bare `@` is an error" behaviour is preserved. Coverage in `internal/cli/stdin_test.go::TestReadFileArgChineseAtMention`.
- **`dws chat` no longer nests as `dws chat chat` when two envelope servers contribute the same top-level command** (#317, see `internal/compat/dynamic_commands.go`) — `BuildDynamicCommands` used to overwrite `topLevel[name]` on the second contribution and rely on `attachOrMerge` later, which then attached the *whole* incoming command (named `chat`) under the existing root, producing `dws chat chat <leaf>`. The new `mergeSubcommandsInto` moves the second contribution's *children* under the first root and drops the duplicate wrapper, so e.g. `group-chat` + `im` envelopes that both declare `cli.command: chat` produce a single flat `dws chat` subtree.
- **Multi-server tool-name authority correction in the runtime runner** (#317, see `internal/app/runner.go` + `internal/app/direct_runtime.go`) — when two envelope servers share the same `cli.command`, the per-product endpoint map `endpoints[cmd]` in `registerDynamicServer` is second-writer-wins, and `catalog.FindProduct` may return the wrong server's endpoint for a tool whose real owner is the *other* server. `runtimeRunner.Run` now cross-checks the canonical tool→endpoint map exposed by the new `directRuntimeToolEndpoint`: when the per-tool endpoint exists and differs from the per-product endpoint the catalog returned, the tool-owner endpoint wins. Pairs with the registry dedup change above so the routing matches the dedup result.
## [1.0.29] - 2026-05-17
Three discovery-envelope products land on the open-source surface — `aiapp` (AI applications), `live` (DingTalk live streaming), and `aisearch` (enterprise people search) — closing the gap with the Wukong edition's product list. The `aisearch` envelope ships rich model-tolerance affordances (short flags, flag aliases, subcommand aliases) so AI agents that hallucinate keyword synonyms (`--query` / `--name` / `--q` / `--text` / `--find`) or alias subcommands (`search` / `find` / `query` / `user` / `people` / ...) still route to the canonical `person` tool instead of erroring out. To support that final fragment of agent tolerance, `internal/compat/registry.go` relaxes the envelope-generated leaf command's `Args` validator from `cobra.NoArgs` to `cobra.ArbitraryArgs` — restoring cobra's own default (`legacyArgs` returns nil for leaves) so trailing positional words are silently ignored. Plus the previously-shipped credential-isolation fix.
### Added
- **`dws aiapp` / `dws live` / `dws aisearch` — three new products discovered via envelope** (no public issue; pre-Diamond rollout) — open-source `dws` now exposes:
- **`dws aiapp`** — AI application lifecycle: `create --prompt <p> [--attachments <json>] [--skills <csv>]` / `query --task-id <id>` / `modify --prompt <p> --thread-id <id> [--skills <csv>]`. Backed by upstream `create_ai_app` / `query_ai_app` / `modify_ai_app` MCP tools.
- **`dws live stream list`** — list my DingTalk live streams. Backed by upstream `get_my_lives`.
- **`dws aisearch person`** — enterprise people search by keyword + multi-dimension filter. Dimensions: `all` (default) / `name` / `department` / `position` / `duty` / `supervisor` / `subordinate` / `phone` / `jobNumber` — multiple comma-separated (`--dimension name,department`). Backed by upstream `enterprise_person_search`.
- The `aisearch` envelope additionally registers `-w` / `-d` short flags (keyword / dimension); hidden flag aliases `--query` / `--name` / `--q` / `--text` / `--find` all routing to `keyword`; and cobra subcommand aliases `search` / `find` / `query` / `user` / `people` / `search-person` / `search-user` / `user-search` / `lookup` / `ask` / `contact` all routing to `person`. This closes the F-class model-tolerance regression cases in `dws-wukong/auto-test/cli_to_mcp/testcases/aisearch/test_90_aisearch_param_regression.py` (50/50 pass for aiapp + live + aisearch on the pre-mcp build).
- **Users must run `dws cache refresh` once** to pick up the new envelopes; no binary upgrade is required, but pairs naturally with the v1.0.29 client (see Fixed below for the envelope-leaf-Args change).
### Fixed
- **Envelope-generated leaf commands now tolerate trailing positional args** (#306, no public issue) — `NewDirectCommand` in `internal/compat/registry.go` was hard-coding `cobra.NoArgs` for leaves without positional bindings (`totalMax == 0`). This is stricter than cobra's own `legacyArgs` (cobra `args.go:30-32` returns `nil` for any command without subcommands), and surfaced as `unknown command "<word>" for "<leaf>"` whenever an AI agent passed trailing positional words after a leaf — e.g. `dws aisearch person search --keyword "张"` or `dws aisearch person user search --keyword "张"`. Switching the `totalMax == 0` branch (and the initial value) from `cobra.NoArgs` to `cobra.ArbitraryArgs` restores cobra's natural leaf behavior: trailing positional args are silently ignored. Existing positional-binding paths (`MinimumNArgs` / `RangeArgs` / `MaximumNArgs`) are unchanged. Verified against `dws-wukong/auto-test/cli_to_mcp/testcases` — aiapp (9/9) + live (3/3) + aisearch (38/38) = **50/50** pass, vs 48/50 before this patch.
### Security
- **App credential files are partitioned by edition to prevent cross-edition credential leakage** (#300, no public issue; found during internal review) — different `dws` editions sharing the same config directory previously read and wrote the same `app.json`. A sibling edition that pinned its OAuth client ID could persist that ID through the shared post-login path, and the open-source build could later adopt it from the same file. Open-source/empty edition keeps the legacy `app.json` path for compatibility; sibling editions now use `app-<edition>.json`, matching the existing cache partitioning strategy. This prevents new cross-edition app credential writes and reads from colliding. After a sibling edition saves its new partitioned file, it also best-effort removes a legacy `~/.dws/app.json` only when that file's `clientId` matches the sibling edition being saved; a different, unparsable, or otherwise unowned `app.json` is left untouched to avoid deleting open-source credentials. If you previously ran multiple editions in one shared `~/.dws`, remove any confirmed-stale orphan manually with `rm ~/.dws/app.json` after verifying it is not the open-source credential file you still need.
## [1.0.28] - 2026-05-14
A single symmetric follow-up to 1.0.26's #250: `dws chat message send --group <cid>` now refuses an empty `--title` at the CLI layer instead of letting the call fall through to the API and surface a misleading `发群服务窗会话消息失败` error. No other behaviour changes.
### Fixed
- **`dws chat message send` rejects missing `--title` on group messages** (#294, completes #250) — `send_message_as_user`'s schema marks `title` as required (just like `send_direct_message_as_user`), but `buildChatMessageSendInvocation` only had the pre-validation on the direct-message branches. Group sends without a title were falling through to the API and returning the same misleading `发群服务窗会话消息失败` that #250 already fixed for direct messages. The check now covers both branches: missing `--title` on `--group` returns `--title is required for group messages (--group)` with exit code 2; missing on `--user` / `--open-dingtalk-id` keeps the original `--title is required for direct messages (--user / --open-dingtalk-id)`. The `Long` help, `--title` flag description, the first `Example`, and `skills/references/products/chat.md` (including the drive→chat workflow example) are realigned to "title is required for both direct and group messages" — the docs previously contradicted themselves (the prose said 群聊可选 while the flag listing said 必填). `internal/helpers/chat_test.go` adds a `group-without-title` rejection case; the existing `group` / `positional-text` success cases now pass `--title` to stay aligned with the new validation. No API request shape change — the server has always required `title`; the CLI now matches.
## [1.0.27] - 2026-05-14
Two user-visible fixes plus the schema primitive they're built on. `dws doc update` now reads Markdown from a file or stdin, so long / multi-line / table-heavy content no longer gets mangled by shell escaping; `dws sheet find --query` stops returning `unknown flag` on the open-source build, restoring copy-paste from internal wukong docs. Underneath, schema/discovery envelopes get a generic `file_read` transform and a `CLIFlagOverride.MapsTo` field that lets two sibling CLI flags route into the same MCP parameter slot. Also suppresses a noisy WARN on normal stdio-plugin shutdown.
### Added
- **`file_read` transform + `CLIFlagOverride.MapsTo` field** (#291, closes #277 #278 #282 #288) — discovery envelopes can now declare a path-typed CLI flag that performs the "file path → file contents string" conversion client-side before the value reaches the upstream MCP parameter.
- `transform: "file_read"` (`internal/compat/transform.go`) — reads the file at the flag's value with UTF-8 validation; `-` means stdin. Any IO / encoding failure is surfaced as a validation error (exit 2), distinct from the generic transient-failure path (exit 1).
- `CLIFlagOverride.MapsTo` (`internal/market/registry.go`) — redirects the flag's final value (post-transform or literal) into a named MCP parameter slot instead of the default `params[propertyName]`. This lets a single MCP parameter (e.g. `markdown`) be fed by two sibling CLI flags — a literal `--content` and a file-reading `--content-file` — paired with the existing tool-level `MutuallyExclusive` / `RequireOneOf` to express "exclusive, at least one".
- Wired into the `internal/compat/dynamic_commands.go` normalizer via a separate `mapsToRoutes` collection + routing pass; empty `MapsTo` preserves the legacy `params[propertyName] = value` semantics, so every pre-existing dynamic_commands test passes unchanged. Pre-prod end-to-end verified across 6 cases (see PR #291's Validation table).
- **`dws doc update --content-file <path>` (envelope rollout)** — fixes "long Markdown can't reach the doc". The old command only accepted `--content "..."`, so long / multi-line / table-heavy Markdown got mangled by shell escaping and AI agents writing >2KB of content were stuck. The envelope now maps both `--content` (literal) and `--content-file` (`file_read` transform) to the `markdown` parameter, makes them mutually exclusive via cobra's `MarkFlagsMutuallyExclusive`, and requires at least one via `RequireOneOf`. `--content-file -` reads from stdin, so `cat long.md | dws doc update --content-file -` works directly. **Existing users must run `dws cache refresh` once** to pick up the new envelope.
- **`dws sheet find --query` hidden alias (envelope rollout)** — fixes "unknown flag when copy-pasting commands across editions". Users copying `dws sheet find --query "..."` from internal wukong docs onto open-source `dws` got `unknown flag: --query`, because the open-source primary flag is named `--find`. The envelope now registers `--query` as a hidden alias of `--find` via `CLIFlagOverride.Aliases` (the field shipped in 1.0.26) — it doesn't show up in `--help`, but accepts values and writes to the same MCP parameter. `--find` behaviour is unchanged. Also requires `dws cache refresh` once.
### Fixed
- **Noisy `failed to stop stdio client: exit status 1` WARN on normal stdio-plugin shutdown** (#285) — when `Stop()` explicitly `Kill`s the subprocess, the non-zero exit code returned by `cmd.Wait()` is expected behaviour, but it was being propagated as an error and logged to stderr on every CLI exit, polluting agent log parsing. `Stop()` now returns `nil` after Kill + Wait; the error path is reserved for "process exited on its own with non-zero" (e.g. stdin close without an explicit Kill). `internal/transport/stdio.go` + `stdio_integration_test.go` assert "Stop() returns nil after kill".
## [1.0.26] - 2026-05-12
Platform-stability round: Windows PAT-auth browser opener no longer truncates URLs at `&userCode=`, macOS sandbox hosts get an opt-in keychain fallback, and `dws doc download` rejects `axls` nodes before requesting `drive:download` consent. Two new global output formats `-f ndjson` and `-f csv` (matching `larksuite/cli`) land as first-class citizens with real-traffic-verified list detection. The `dws doc comment *` regression tracked in #240 is also resolved — fix is in the market metadata, users just need `dws cache refresh` once.
### Added
- **`-f ndjson` and `-f csv` global output formats** (#259, closes #252) — `ndjson` emits one compact JSON record per line (works straight with `jq -c` / `while read` / log pipelines); `csv` goes through `encoding/csv` (RFC-4180 — quoting, embedded newlines, CJK all handled by stdlib) and reuses the existing `-f table` column resolver (`normalizePayload` / `unwrapPrimaryObject` / `extractRowsFromMap` / `rowsFromSlice` / `formatValue`) so table and csv stay visually aligned. After a 7-product real-traffic sweep (contact / chat / doc / mail / todo / minutes / schema), the `preferredListKeys` whitelist was extended to cover the actual DingTalk envelope shapes — `contact user search` (`result`), `chat search` (`result.value`), `doc search` (`documents`), `mail mailbox list` (`emailAccounts`), `todo task list` (`result.todoCards`) — so these commands now degrade into a proper row stream instead of collapsing to a single-line `key,value` blob. Lives in `internal/output/ndjson.go` + `internal/output/csv.go`; `--format` help in `internal/app/flags.go` now lists `ndjson|csv` alongside `json|table|raw|pretty`.
### Changed
- **Sticky flag splitting is now schema-aware** (#272) — PreParse `StickyHandler` 此前会把任何前缀命中已知 flag 的 `--flagsuffix` 一律切成 `--flag suffix`,于是 `--starttime20260507` 这类拼错被静默改写成 `--start time20260507`,把假值传到下游。新行为按 flag 的 pflag 类型 / JSON Schema `format` / `enum` 校验 suffix 是否像合法 value(共享逻辑见 `pkg/cmdutil/sticky_suffix.go`),不像就保留原 token 让 cobra 报 `unknown flag`。slice/array/object 类型的 flag 永不切分。首 rune 读取使用 `utf8.DecodeRuneInString`,对中文等多字节 value 安全。
### Added
- **`available_flags` field on unknown-flag errors** (#272) — `dws -f json` 的 unknown-flag 错误体里新增 `available_flags`(已排序、过滤掉 hidden 与内部 `json` / `params`),方便 agent 不解析 `--help` 就能恢复。Human-readable 输出会附 `Flags: ...` 行,截断在 200 字节内。
### Fixed
- **`dws chat message send` 单聊缺 `--title` 时前置校验** (#250) — 单聊(`--user` / `--open-dingtalk-id`)的底层工具 `send_direct_message_as_user` 在 API 层强制要求 title,缺失时返回误导性的 `发群服务窗会话消息失败`。CLI 现在在 `buildChatMessageSendInvocation` 里前置校验,直接返回 `--title is required for direct messages (--user / --open-dingtalk-id)`;同时把 `Long` help、`--title` flag 描述、Example 和 `skills/references/products/chat.md` 全部对齐为「单聊必填,群聊可选」。群聊行为不变。
- **PAT auth URLs were truncated on Windows browser open** (#242, fixes #230) — `cmd /c start <url>` on Windows interprets `&` as a command separator, so PAT URLs containing `&userCode=...` were silently chopped before the userCode segment, and the browser landed on a 0-permission DingTalk page. The retry opener now uses `rundll32 url.dll,FileProtocolHandler`, which passes the URL through verbatim. The PAT response also exposes a copy-safe `data.authorizationUrl` (in addition to the service-provided `data.uri`, which is preserved as-is), and human-readable PAT output prints `PAT_AUTHORIZATION_URL=<full-url>` on its own line so OpenClaw-style host wrappers that swallow or reformat stderr can still capture the full link. Legacy DingTalk hash-route shapes (`https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId=...%26userCode=...`) are normalised back into the working `/fe/old?hash=...#/personalAuthorization?...&userCode=...` form. Regression tests cover the issue-shaped URLs (encoded hash, fragment, `&userCode`) plus the OpenClaw malformed-hash variant.
- **`dws doc download` triggered `drive:download` PAT consent for unsupported axls nodes** (#268, fixes #190) — added a `get_document_info` preflight before `download_file`, so online-sheet (`axls`) nodes are rejected locally with guidance to use sheet range tools instead. The preflight reads `extension` from deterministic response paths (no recursive payload scan) and routes its own PAT errors back through `handlePatAuthCheck`, preserving device-flow / host-owned PAT behaviour. Costs one extra MCP roundtrip per `doc download` — deliberate, so the unsupported path fails before consent. Lives in `internal/app/doc_download_preflight.go`; coverage in `internal/app/runner_test.go`.
- **macOS sandbox hosts (Codex App etc.) couldn't read/write tokens via Keychain** (#267, fixes #214) — sandboxed macOS environments intercept `security` / Keychain APIs, so every token operation failed. New opt-in `DWS_DISABLE_KEYCHAIN=1` switches macOS to the same file-DEK path Linux uses (DEK at `~/Library/Application Support/dws-cli/dek`, mode `0600`), bypassing the system Keychain. Default behaviour is unchanged — fallback is strictly opt-in because file-DEK is a weaker trust model than Keychain-managed storage (DEK file sits next to ciphertext in the same directory). The Darwin / Linux file-DEK implementation is now shared in `internal/keychain/file_dek.go` (Linux path deduplicated by ~40 lines). Documented in `docs/reference.md` (中英) with the security tradeoff spelt out so users make the choice explicitly.
- **`dws doc comment {list,create,create-inline,reply}` returned `PARAM_ERROR - 未找到指定工具`** (fixes #240, also #234) — the four comment tools used to live on an independent `doc-comment` MCP server. After the Portal merged comment functionality into the `doc` server descriptor, the runtime `tools/list` on the merged `doc` server didn't include them, so every `dws doc comment *` call returned the "tool not found" PARAM_ERROR. The market metadata for the `doc` server now declares `serverOverride: "doc-comment"` on all four comment `toolOverrides`, so the existing CLI routing path sends `dws doc comment *` to the still-running `doc-comment` MCP server (which has the tools). No CLI code change was required, but **existing users must run `dws cache refresh` once** to pick up the updated descriptor — without that, the stale local market cache keeps pointing the call at the merged `doc` server and the error persists. Verified post-refresh: dry-run resolves to `https://mcp-gw.dingtalk.com/server/doc-comment` with tool `list_comments`, real calls return normal business responses (e.g. legitimate cross-org authz errors) instead of `未找到指定工具`.
## [1.0.25] - 2026-05-11
Two generic envelope-schema enhancements that close gaps the `cli_to_mcp` test suite kept surfacing — both product-agnostic, no hardcoded helper commands. Plus missing skill references for the already-registered `sheet` and `wiki` products are now shipped.
### Added
- **`sheet` (在线电子表格) skill reference + product-overview entry** — the `sheet` product registers **34 envelope tools** covering worksheet CRUD (`create` / `new` / `list` / `info` / `copy_sheet` / `update_sheet`), range read/write (`range read` / `range update` / `append`), dimension ops (`add-dimension` / `insert-dimension` / `delete-dimension` / `move-dimension` / `update-dimension`), merge (`merge-cells` / `unmerge-cells`), find/replace (`find` / `replace`), filter views (`filter-view {create, list, update, delete, update-criteria, delete-criteria}`), sheet-level filters (`create_filter` / `get_filter` / `update_filter` / `delete_filter` / `set_filter_criteria` / `clear_filter_criteria` / `sort_filter`), image write (`write-image`), and async export (`submit_export_job` + `query_export_job`). These were live in the envelope but `skills/references/products/sheet.md` had not shipped and `skills/SKILL.md` 产品总览 didn't list `sheet`, so agents had no reference to consult and were skipping it during intent routing. This release adds the doc, registers `sheet` in 产品总览 + 意图判断决策树, extends `description` to include 在线电子表格, adds a Sheet row to `README.md` / `README_zh.md` "Key Services", and notes the v1.0.25 reality on naming (about a third of `sheet` tools still expose snake_case cli_names pending `CLIAliases` (#246) rollout) and on export (no consolidated `dws sheet export` exists in v1.0.25 — `submit_export_job` + `query_export_job` are the atomic primitives; Pipeline (#247) provides the future plumbing).
- **`wiki` (知识库) skill reference + product-overview entry** — the wiki product's 7 envelope tools (`wiki.create_wikiSpace`, `wiki.get_wikiSpace`, `wiki.list_wikiSpaces`, `wiki.search_wikiSpaces`, `wiki.add_member`, `wiki.list_member`, `wiki.update_member`, surfaced as `dws wiki space create / get / list / search` and `dws wiki member add / list / update`) have been registered for a while, but no `skills/references/products/wiki.md` shipped with them, so agents had no per-command reference to consult. This release adds the reference doc, registers `wiki` in `skills/SKILL.md`'s 产品总览 table and 意图判断决策树, mentions 知识库 in the skill `description` frontmatter, adds a Wiki row to `README.md` / `README_zh.md` "Key Services", and removes `wiki` from the "Coming soon" callout (which was now stale).
- **`CLIToolOverride.CLIAliases` envelope field** (#246) — lets a single MCP tool register additional cobra command aliases via envelope JSON (e.g. `range read` also accepts `range get`, `member list` accepts `member ls`). Plumbed through the existing `Route.Aliases → cobra.Command.Aliases` path; sibling conflicts are silently dropped by cobra. Lives in `internal/market/registry.go` + `internal/compat/dynamic_commands.go`.
- **`json_parse_strict` transform** (#246) — strict-JSON variant of `json_parse` that does **not** fall back to YAML. Use when the upstream tool requires a structured array/object and silently coercing a malformed input to a scalar string would mask a real user error (observed: `filter-view --criteria 'NOT_VALID_JSON'` was being accepted and quietly creating an empty-criteria view). In `internal/compat/transform.go`.
- **`CLIToolOverride.Pipeline` + pipeline executor** (#247) — a single CLI command can now orchestrate an ordered sequence of MCP tool calls plus optional HTTP-download sinks, declared entirely in envelope JSON. Motivating use case: the "submit-job → poll-status → download-result" pattern (e.g. sheet export) that previously required per-product hardcoded helpers.
- `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.
+63
View File
@@ -0,0 +1,63 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.52-beta.5"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-darwin-arm64.tar.gz"
sha256 "7164f2b0389ce0c3bc1d745b5c98082c1ef92c8547c9b123dcb4e83fe172f92e"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-darwin-amd64.tar.gz"
sha256 "6ebd48fb96009cf2a81eb0af15216ba050620db55470d5c9937467aa66558879"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-linux-arm64.tar.gz"
sha256 "5f718244665c33a9327130874788d0fad36824ec29eb437ab82aa83e3d5a0579"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-linux-amd64.tar.gz"
sha256 "e79abccc1e093b946be89282bd034ba60ab479cc8ee1a51001eb0d441c66125c"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52-beta.5/dws-skills.zip"
sha256 "64c48271de89a94f9c184a475692e0e2f5e23bc0480c10824f717b21e3a83097"
end
def install
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
bin.install binary => "dws"
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
source = File.join(root, name)
pkgshare.install source if File.exist?(source)
end
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
cp_r(Dir["*"], skill_dest)
end
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
This beta is keg-only. Add #{opt_bin} to PATH to use its `dws` binary.
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/dws version")
end
end
+61
View File
@@ -0,0 +1,61 @@
class DingtalkWorkspaceCli < Formula
desc "Automate DingTalk workspace tasks from the terminal"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.52"
license "Apache-2.0"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-arm64.tar.gz"
sha256 "4f6b4d064a76bcefac42feb5f356253fe43f9499b8cec9d2cdf202e7d3b9b60c"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz"
sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-arm64.tar.gz"
sha256 "0d357ef0535f99f2f63b5ecbfdee9c32448be2a2c24f3096c03126b3b7570bc5"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz"
sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-skills.zip"
sha256 "0fa3c8dec500c1659e6480d6772ae901b2d12d24322dd5d7283f016024290c21"
end
def install
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
bin.install binary => "dws"
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
source = File.join(root, name)
pkgshare.install source if File.exist?(source)
end
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
cp_r(Dir["*"], skill_dest)
end
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/dws version")
end
end
+96 -5
View File
@@ -1,6 +1,9 @@
GO ?= go
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
.PHONY: all help build rebuild test lint fmt policy edition-test package release publish-homebrew-formula setup-hooks
.PHONY: all help build rebuild test lint fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-metadata generate-schema-catalog package release publish-homebrew-formula setup-hooks
all: setup-hooks fmt lint build test rebuild
@@ -10,7 +13,21 @@ help:
@printf " make test - Run the Go test suite\n"
@printf " make lint - Run formatting checks and golangci-lint when available\n"
@printf " make fmt - Format Go source files\n"
@printf " make policy - Run open-source asset and command-surface checks\n"
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and changed-code coverage\n"
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce native-platform changed-code coverage\n"
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
@printf " make cli-smoke - Verify help for every public top-level command\n"
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
@printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n"
@printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n"
@printf " make generate-schema-catalog - Regenerate the embedded release Catalog\n"
@printf " make package - Build all release artifacts locally (goreleaser snapshot)\n"
@printf " make release - Build and publish a release via goreleaser\n"
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
@@ -28,15 +45,89 @@ lint:
@./scripts/dev/lint.sh
fmt:
@find cmd internal test -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
@find cmd internal test scripts/policy -name '*.go' -print0 2>/dev/null | xargs -0r gofmt -w
policy:
@./scripts/policy/check-open-source-assets.sh
@./scripts/policy/check-command-surface.sh --strict
@mkdir -p "$(POLICY_GOTMPDIR)"
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
edition-test:
$(GO) test -v -count=1 ./pkg/editiontest/...
interface-integrity:
@./scripts/policy/check-interface-baseline.sh
authoritative-interface-integrity:
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
coverage-gate:
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
coverage-gate-platform:
@./scripts/policy/run-platform-coverage-gate.sh --base-ref "$(BASE_REF)" --profile "$(PROFILE)"
update-interface-baseline:
@./scripts/policy/check-interface-baseline.sh --update
reset-interface-baseline:
@./scripts/policy/check-interface-baseline.sh --reset
schema-compatibility:
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
skill-command-integrity:
@./scripts/policy/check-skill-commands.sh
cli-smoke:
@./scripts/policy/check-cli-smoke.sh
mock-mcp-smoke:
$(GO) test -v -count=1 -run '^(TestHTTPClientEndToEnd|TestStdioClientEndToEnd)$$' ./internal/transport
test-schema-agent-examples:
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestManualAgentExamplesDryRun$$'
generate-schema:
@set -e; \
registry_guard=$$(mktemp); \
metadata_guard=$$(mktemp -d); \
selection_guard=$$(mktemp -d); \
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
cp internal/cli/schema_command_registry.json "$$registry_guard"; \
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
$(GO) generate ./internal/cli; \
cmp -s internal/cli/schema_command_registry.json "$$registry_guard" || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry.json' >&2; \
exit 1; \
}; \
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \
exit 1; \
}; \
diff -qr internal/cli/schema_hints/selection "$$selection_guard" >/dev/null || { \
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/selection' >&2; \
exit 1; \
}
generate-schema-agent-metadata:
$(GO) run ./internal/generator/cmd_schema_agent_metadata \
-root . \
-registry internal/cli/schema_command_registry.json \
-output-dir internal/cli/schema_agent_metadata \
-audit-output internal/cli/schema_agent_metadata_audit.json
generate-schema-catalog:
$(GO) run -a ./internal/generator/cmd_schema_catalog \
-root . \
-output internal/cli/schema_catalog.json
package:
@./scripts/dev/build-all.sh
@./scripts/release/post-goreleaser.sh
+308 -47
View File
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **Co-creation Phase**: This project accesses DingTalk enterprise data and requires enterprise admin authorization. Join the DingTalk DWS co-creation group for support and updates. See [Getting Started](#getting-started) below.
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws Open Source Community DingTalk Group QR Code" width="150">
<details>
<summary><strong>Table of Contents</strong></summary>
@@ -63,6 +63,27 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
<details>
<summary><strong>Skill mode: mono vs multi</strong></summary>
The installer ships skills in one of two layouts. CLI commands (`dws aitable ...`, `dws calendar ...`) are identical in both modes — only the agent-side skill layout differs.
| Mode | What gets installed | Best for |
|------|----------------------|----------|
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
How to pick:
- **Quick install** (one-liner above): non-interactive, installs `mono`.
- **TTY install** (download then run): `curl -O .../install.sh && bash install.sh` — prompts `1) mono 2) multi` (default 1).
- **Override via env**: `DWS_SKILL_MODE=multi curl -fsSL ... | sh`.
- **Switch later**: `dws skill setup --mode multi` (or `--mode mono`) — re-run any time.
</details>
<details>
<summary>Other install methods</summary>
@@ -72,6 +93,30 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
npm install -g dingtalk-workspace-cli
```
Install the latest beta:
```bash
npm install -g dingtalk-workspace-cli@beta
```
**Homebrew** (macOS / Linux):
```bash
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
brew install dingtalk-workspace-cli
```
> The Formula lives in this repository, so the first `tap` command must include the explicit repository URL. Afterwards, use `brew upgrade dingtalk-workspace-cli` normally.
Install the keg-only Homebrew beta without replacing the stable Formula:
```bash
brew install dingtalk-workspace-cli-beta
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
```
To make the beta `dws` the default for the current shell, prepend `$(brew --prefix dingtalk-workspace-cli-beta)/bin` to PATH.
**Pre-built binary**: download from [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases).
> **macOS users**: If you see "cannot be opened because Apple cannot check it for malicious software", run:
@@ -88,10 +133,44 @@ go build -o dws ./cmd # build to current directory
cp dws ~/.local/bin/ # install to PATH
```
Static endpoint data is generated from the Wukong baseline and committed in this
repository under `internal/syncdata`, so source builds do not require a sibling
data checkout.
> Requires Go 1.25+. Use `make package` to cross-compile for all platforms (macOS / Linux / Windows x amd64 / arm64).
</details>
## China mirror
For users in mainland China, the following channels avoid GitHub network issues. By default (without setting these environment variables) the installer pulls from GitHub.
**1. Install script + pre-built binary (Gitee mirror):**
Repository mirror: `https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
> With `DWS_GITEE_REPO` set, the installer resolves the latest version and every release asset (binary, checksums, skills) from the Gitee API instead of GitHub. If it is unset, installation defaults to GitHub.
**2. npm package (npmmirror mirror):**
```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
```
> npmmirror automatically syncs public packages from the public npm registry, so this works directly in China.
**3. Skills only (Gitee mirror):**
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
```
> With `DWS_GITEE_REPO` set, `install-skills.sh` resolves the version and skills package from Gitee; it also auto-falls back to the Gitee mirror when GitHub is unreachable.
## Upgrade
> Requires **v1.0.7** or later. For earlier versions, please re-run the [install script](#installation) to upgrade.
@@ -101,12 +180,30 @@ dws has built-in self-upgrade capability. Updates are pulled directly from [GitH
```bash
dws upgrade # interactive upgrade to latest version
dws upgrade --check # check for new versions without installing
dws upgrade --list # list all available versions
dws upgrade --list # list stable release versions
dws upgrade --beta # upgrade to the latest beta pre-release
dws upgrade --check --beta # check the beta track without installing
dws upgrade --list --beta # list beta pre-release versions
dws upgrade --version v1.0.7 # upgrade to a specific version
dws upgrade --version v1.0.8-beta.1 # upgrade to a specific beta version
dws upgrade --rollback # rollback to the previous version
dws upgrade -y # skip confirmation prompt
```
By default, `dws upgrade` follows the stable release track. Use `--beta` only when you explicitly want the newest GitHub pre-release build.
### Six-channel post-release verification
Maintainers and release validators can run the release-quality smoke checks for curl, PowerShell, npm stable, npm beta, Homebrew, and `dws upgrade`:
```bash
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
cd /tmp/dws-verify/verify
bash verify-all-channels.sh
```
The verifier uses isolated directories and does not replace the `dws` on the current PATH. It reports `PASS`, `FAIL`, and `SKIP`; a platform skip is not a pass and must be covered on the matching host. See [`verify/README.md`](verify/README.md) for the platform matrix.
<details>
<summary><strong>How it works</strong></summary>
@@ -120,8 +217,9 @@ A backup of the current version is automatically created before each upgrade. Us
| Flag | Description |
|------|-------------|
| `--check` | Check for updates without installing |
| `--list` | List all available versions with changelogs |
| `--version` | Upgrade to a specific version (e.g. `v1.0.7`) |
| `--list` | List available stable release versions with changelogs |
| `--beta` | Use the beta pre-release track for `upgrade`, `--check`, or `--list` |
| `--version` | Upgrade to a specific version (e.g. `v1.0.7` or `v1.0.8-beta.1`) |
| `--rollback` | Rollback to the previous backed-up version |
| `--force` | Force reinstall even if already on the latest version |
| `--skip-skills` | Skip skill package update |
@@ -182,6 +280,52 @@ Credentials are securely persisted after first login (Keychain). Subsequent runs
</details>
<details>
<summary><strong>Multiple organizations (profiles)</strong></summary>
`dws` can stay logged in to several DingTalk organizations at once. Each organization is one **profile**; the current profile decides which org a command runs against (credentials are stored per organization).
```bash
dws auth login # log in to another org → adds a profile (first login becomes the primary)
dws profile list # list logged-in orgs (primary / current marker, status)
dws profile switch <name|corpId> # switch the default org (use - to toggle back to the previous one)
dws --profile <name|corpId> contact user search --query "..." # run one command against a specific org, without changing the default
```
Cross-org reads are orchestrated by the agent rather than a built-in `--all-orgs`: list the profiles, run the query per org with `--profile`, then merge. Writes default to the current org only — confirm the target org before writing across orgs.
On macOS, an unreadable registered token slot blocks a new OAuth login rather than risking a mixed Keychain/file-DEK state. If normal terminal commands can still read the login while a sandbox using `DWS_DISABLE_KEYCHAIN=1` cannot, migrate the legacy and profile auth entries without exposing tokens:
```bash
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
```
The migration validates every selected auth ciphertext before writing, ignores unrelated application secrets, and can be rerun after an interrupted commit. If validation identifies genuinely damaged ciphertext, remove only the affected profile with `dws auth logout --profile <name|corpId>`, then log in again. Use `dws auth reset` only when you intend to discard every local profile.
</details>
<details>
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
Copying only `~/.dws/app.json` does not carry the refresh token; access tokens expire after ~2 hours. Use the official export/import flow:
```bash
# Sandbox A (already logged in)
dws auth export -o /tmp/dws-auth.tar.gz
# Or for copy/paste: dws auth export --base64 -o /tmp/dws-auth.b64
# Sandbox B
dws auth import -i /tmp/dws-auth.tar.gz
# Or: dws auth import -i /tmp/dws-auth.b64 --base64
dws auth status # confirm "Refresh Token: valid"
```
The bundle includes the encrypted keychain under `~/.local/share/dws-cli` (with `auth-token.enc` and `dek`) plus required `~/.dws` config files.
</details>
## Quick Start
```bash
@@ -213,43 +357,85 @@ dws contact user search --query "engineering" --dry-run
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
```
### Schema Discovery
### Command Help and Schema
Agents don't need pre-built knowledge of every command. Use `dws schema` to dynamically discover capabilities:
Use Cobra help and Schema for different parts of the command contract:
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
```bash
# Step 1: Discover all available products
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
# Confirm that the command exists and inspect accepted flags
dws aitable record query --help
# Step 2: Inspect target tool's parameter schema
dws schema aitable.query_records --jq '.tool.parameters'
# Discover within a product, then inspect the selected leaf contract
dws schema aitable
dws schema "aitable record query"
# Step 3: Construct the correct call
# Execute the real business query
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
### Agent Skills
The repo ships a complete Agent Skill system (`skills/`). After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
Shared reviewed inputs for Schema generation live separately under `internal/cli/schema_hints/`. They are not Agent Skills and are excluded from binaries and release skill bundles.
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
```bash
# Install skills into current project
# Install skills into current project (defaults to mono)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` installs to `$HOME/.agents/skills/dws` (global); `install-skills.sh` installs to `./.agents/skills/dws` (current project).
>
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
**What's included:**
**Switching or re-installing with `dws skill setup`:**
```bash
# Interactive: prompts for mode + target agents
dws skill setup
# Install mono skill to every detected agent home (claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# Install multi skills to a single agent home
dws skill setup --mode multi --target cursor --yes
# Point at a local source tree (e.g. a fork or work-in-progress)
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| Flag | Values | Description |
|------|--------|-------------|
| `--mode` | `mono` \| `multi` | Skill layout; defaults to interactive prompt |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | Where to install; `all` covers every detected agent home |
| `--source` | path | Local source directory (overrides bundled skills) |
| `--yes` | — | Skip confirmation prompts |
Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.ps1`), `DWS_SKILL_SOURCE=<path>`.
**What's included (mono layout):**
| Component | Path | Description |
|-----------|------|-------------|
| Master Skill | `SKILL.md` | Intent routing, decision tree, safety rules, error handling |
| Product references | `references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
| Intent guide | `references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `scripts/*.py` | 13 batch operation scripts (see below) |
| Master Skill | `skills/mono/SKILL.md` | Intent routing, decision tree, safety rules, error handling |
| Product references | `skills/mono/references/products/*.md` | Per-product command reference (aitable, chat, calendar, etc.) |
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
<details>
<summary><strong>Ready-made scripts</strong> — 13 Python scripts for common multi-step workflows</summary>
@@ -276,6 +462,51 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
## Features
<details>
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
> **Prerequisite**: run `dws auth login`. Personal identity is resolved from the OAuth token and cannot be supplied through command-line identity flags.
For an event-focused installation, use the official convenience installer:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
```
```bash
# Inspect the public personal event catalog and schema
dws event list
dws event schema user_im_message_receive_o2o
# Listen for messages that mention the current user
dws event consume user_im_message_receive_at -f ndjson
# Listen for one-to-one messages with a specified user
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
# Inspect local consumers and cancel a subscription
dws event status
dws event stop <subscribe_id>
```
| Feature | Details |
|---------|---------|
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
| Shared connection | Consumers for the same user share one local bus and cloud connection |
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
</details>
<details>
<summary><strong>Raw API Access</strong> — call any DingTalk OpenAPI directly</summary>
@@ -357,7 +588,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# Built-in jq expressions
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
dws schema "dev app create" --jq '.tool.required'
# Return only specific fields
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -366,13 +597,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
</details>
<details>
<summary><strong>Schema Introspection</strong> — query parameter schemas before making calls</summary>
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
```bash
dws schema # list all products and tools
dws schema aitable.query_records # view parameter schema
dws schema aitable.query_records --jq '.tool.required' # view required fields
dws schema --jq '.products[].id' # extract all product IDs
dws schema aitable # discover product commands
dws schema "aitable record query" # view the selected leaf contract
dws schema "aitable record query" --jq '.tool.required' # view required fields
dws schema --all # full export for CI/audit/baselines
```
</details>
@@ -394,36 +625,65 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
--title "Weekly Report" --text @-
```
> **Note**: `@` is treated as the `@<path>` file-injection prefix only when the next character is an ASCII path-shaped character (`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`), or `@-` for stdin. Chat-bot payloads like `--text "@所有人 周报"` or `--text "@张三 看一下"` pass through unchanged, so literal mentions reach the API as-is.
</details>
## DingTalk bot — connect a robot to your local AI
`dws dev connect` bridges a DingTalk robot to a local AI CLI (Claude Code / Codex / opencode / Qoder / Gemini, or any tool via `--agent-cmd`): @-mention the bot in a chat and it answers using your local agent, keeping per-conversation multi-turn memory.
```bash
dws dev connect --channel auto --unified-app-id <unifiedAppId>
```
> `--unified-app-id` resolves `clientSecret` at runtime via `dev app credentials get`,
> so the secret never appears in argv (`ps` / journald / shell history). The
> legacy `--robot-client-id <id> --robot-client-secret <secret>` still works but
> the CLI will warn you.
In-chat **session commands** (send the bare command as the whole message — no agent turn, no tokens):
| Command | Effect |
|---------|--------|
| `/new` (aliases `/start`, `/reset`) | Start a fresh session; the previous one is left intact (resumable where the agent supports it) |
| `/clear` | Wipe the current session — disposed through the agent's real session op (opencode issues `DELETE /session/:id`); channels whose agent exposes no delete primitive fall back to a reset |
See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step walkthrough (install → create robot → connect → add to a group).
## Key Services
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| Contact | `contact` | 6 | `user` `dept` | Search users by name/mobile, batch query, departments, current user profile |
| Chat / IM | `chat` (alias `im`) | 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | Messages (send / list / list-all / by-sender / mentions / focused / unread / topic replies / search), group CRUD + member management (incl. `add-bot`), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common groups lookup |
| Calendar | `calendar` | 14 | `event` `room` `participant` `busy` | Events CRUD + suggested times + attachments, meeting room booking, free-busy query, participant management |
| Todo | `todo` | 6 | `task` | Create, list, update, done, get detail, delete |
| Approval | `oa` | 9 | `approval` | Approve / reject / revoke, pending / initiated instances, process list, operation records |
| Attendance | `attendance` | 4 | `record` `shift` `summary` `rules` | Clock-in records, shift schedules, attendance summary, group rules |
| Ding | `ding` | 2 | `message` | Send / recall DING messages |
| Report | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | Create reports, sent/received list, templates, statistics |
| AI Tables | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Full CRUD for Bases / datasheets / records / fields / views; charts & dashboards with public-share configs; data import/export; attachments; templates |
| Doc | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), upload / download |
| Drive | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | DingTalk drive file ops: list, info, download, create folders, two-phase upload |
| 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 |
| 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 |
| Service | Command | Capabilities |
|---------|---------|--------------|
| Contact | `contact` | Look up users by name / mobile / job-number, departments, labels & roles, roster profiles & dismissals |
| Chat / IM | `chat` (`im`) | Send / reply / search messages, group & member management, bot & webhook messaging, reactions, recall |
| Calendar | `calendar` | Events CRUD, attendees, meeting rooms, free/busy & time suggestions |
| Todo | `todo` | Create / list / update / complete tasks and comments |
| Approval | `oa` | Approve / reject / revoke / transfer; query pending / initiated / CC instances and forms |
| Attendance | `attendance` | Clock-in records, shifts, summaries, group rules (read-only) |
| Ding | `ding` | Send / recall DING messages |
| Report | `report` | Create / submit logs, inbox & outbox, templates, statistics |
| AI Tables | `aitable` | Bases / tables / records / fields / views, permissions & roles, automation, charts & dashboards, import / export |
| Doc | `doc` | Search / read / write docs, block-level editing, comments, permissions, media, up / download |
| Drive | `drive` | List / search / download, folders, upload, copy / move / rename, permissions |
| Minutes | `minutes` | AI meeting notes: list, summary / keywords / transcription / todos, mind map, speakers, tags |
| Mail | `mail` | Mailboxes, KQL search, read / send, drafts, folders, templates, contacts |
| Sheet | `sheet` | Online spreadsheets: worksheet & range read / write, filters, conditional format, images, CSV |
| Wiki | `wiki` | Knowledge bases: spaces, members, node tree, docs & files |
| DevDoc | `devdoc` | Search the Open Platform docs and diagnose API errors |
| AI Search | `aisearch` | Enterprise people search by name / dept / role / duty / supervisor / phone / job-number |
| Live | `live` | List my live streams |
| Raw API | `api` | Call any DingTalk OpenAPI directly, with managed app-level token |
> **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.
> Full command listing with usage scenarios: [`docs/command-index.md`](./docs/command-index.md). Run `dws --help` for the top-level tree, or `dws <service> --help` for any service's subcommands.
> **Note on `chat bot`**: bot capabilities (`send-by-bot` / `recall-by-bot` / `add-bot` / `send-by-webhook` / bot search) are merged into the relevant `chat` subtrees (e.g. `dws chat message send-by-bot`, `dws chat group members add-bot`) so the agent-facing command surface stays flat and discoverable. There is no longer a separate top-level `bot` product.
<details>
<summary>Coming soon</summary>
`conference` (video) · `aiapp` (AI apps) · `live` (streaming) · `wiki` (knowledge base)
- `conference` (video meetings)
- Multi-skill mode (experimental) — per-product skills under `skills/multi/`; opt in via `dws skill setup --mode multi`
</details>
@@ -474,7 +734,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
- [Command Index](./docs/command-index.md) — every runtime command with description and when-to-use guidance
- [Reference](./docs/reference.md) — environment variables, exit codes, output formats, shell completion
- [Architecture](./docs/architecture.md) — discovery-driven pipeline, IR, transport layer
- [Architecture](./docs/architecture.md) — static endpoint pipeline, command surface, transport layer
- [Open Platform App Command Routing](./docs/dev-yulan-command-routing.md) — yulan dev app command design, MCP overlay, permission flow, and Agent routing
- [Changelog](./CHANGELOG.md) — release history and migration notes
## Contributing
+300 -47
View File
@@ -21,7 +21,7 @@
> [!IMPORTANT]
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
>
> <a href="https://qr.dingtalk.com/action/joingroup?code=v1,k1,v9/YMJG9qXhvFk5juktYnQziN70rF7QHebC/JLztTVRuRVJIwrSsXmL8oFqU5ajJ&_dt_no_comment=1&origin=11"><img src="https://img.alicdn.com/imgextra/i4/O1CN01Rijgk81gKqVSKMzdx_!!6000000004124-2-tps-654-644.png" alt="DingTalk Group QR Code" width="150"></a>
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
<details>
<summary><strong>目录</strong></summary>
@@ -63,6 +63,27 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
<details>
<summary><strong>Skill 模式:mono 与 multi</strong></summary>
安装时可以选择两种 skill 组织方式。两种模式下 CLI 命令完全一样(`dws aitable ...` / `dws calendar ...`),区别只在 Agent 那边读到的 skill 文档结构。
| 模式 | 安装内容 | 适合场景 |
|------|----------|----------|
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
怎么选:
- **快速安装**(上方一行 curl):非交互,默认装 `mono`。
- **TTY 安装**(先下载再执行):`curl -O .../install.sh && bash install.sh`,会弹出 `1) mono 2) multi` 选项(默认 1)。
- **环境变量覆盖**:`DWS_SKILL_MODE=multi curl -fsSL ... | sh`。
- **装完之后再切换**:`dws skill setup --mode multi`(或 `--mode mono`),随时重跑都行。
</details>
<details>
<summary>其他安装方式</summary>
@@ -72,6 +93,30 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
npm install -g dingtalk-workspace-cli
```
安装最新 beta:
```bash
npm install -g dingtalk-workspace-cli@beta
```
**Homebrew**(macOS / Linux):
```bash
brew tap DingTalk-Real-AI/dingtalk-workspace-cli https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git
brew install dingtalk-workspace-cli
```
> Formula 与代码位于同一个仓库,因此首次 `tap` 需要显式指定仓库 URL。后续可直接使用 `brew upgrade dingtalk-workspace-cli`。
安装 Homebrew beta(keg-only,不覆盖稳定版):
```bash
brew install dingtalk-workspace-cli-beta
$(brew --prefix dingtalk-workspace-cli-beta)/bin/dws version
```
如需让 beta 的 `dws` 成为当前 shell 默认版本,将 `$(brew --prefix dingtalk-workspace-cli-beta)/bin` 放到 PATH 最前面。
**预编译二进制文件**:从 [GitHub Releases](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases) 下载。
> **macOS 用户注意**:如果提示“无法打开,因为 Apple 无法检查其是否包含恶意软件”,请执行:
@@ -89,9 +134,40 @@ cp dws ~/.local/bin/ # 安装到 PATH
```
> 需要 Go 1.25+。也可以用 `make package` 构建所有平台产物(macOS / Linux / Windows × amd64 / arm64)。
> 静态端点数据由悟空基线生成并提交在本仓库 `internal/syncdata`,源码构建不需要额外 checkout 数据仓库。
</details>
## 国内加速安装
国内用户可使用以下通道,避免 GitHub 网络问题。默认(不设置这些环境变量)走 GitHub。
**1. 安装脚本 + 预编译二进制(Gitee 镜像):**
仓库镜像地址:`https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli`
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
> 设置 `DWS_GITEE_REPO` 后,安装脚本会改从 Gitee API 解析最新版本和各个 release 产物(二进制、校验和、skills 包),而不是走 GitHub。不设置时默认从 GitHub 安装。
**2. npm 包(npmmirror 镜像):**
```bash
npm install -g dingtalk-workspace-cli --registry=https://registry.npmmirror.com
```
> npmmirror 会自动同步公网 npm 的公开包,国内可直接使用。
**3. 单独安装 Skills(Gitee 镜像):**
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install-skills.sh | sh
```
> 同样设置 `DWS_GITEE_REPO`,`install-skills.sh` 会从 Gitee 解析版本和 skills 包;GitHub 不可达时也会自动回退到 Gitee 镜像。
## 升级
> 需要 **v1.0.7** 及以上版本。更早版本请重新执行[安装脚本](#安装)进行升级。
@@ -101,12 +177,30 @@ dws 内置自升级能力,直接从 [GitHub Releases](https://github.com/DingT
```bash
dws upgrade # 交互式升级到最新版本
dws upgrade --check # 仅检查是否有新版本
dws upgrade --list # 列出所有可用版本
dws upgrade --list # 列出正式 release 版本
dws upgrade --beta # 升级到最新 beta 预发布版本
dws upgrade --check --beta # 仅检查 beta 轨道是否有新版本
dws upgrade --list --beta # 列出 beta 预发布版本
dws upgrade --version v1.0.7 # 升级到指定版本
dws upgrade --version v1.0.8-beta.1 # 升级到指定 beta 版本
dws upgrade --rollback # 回滚到上一版本
dws upgrade -y # 跳过确认直接升级
```
默认情况下,`dws upgrade` 只跟随正式 release 轨道。只有显式传入 `--beta` 时,才会选择 GitHub pre-release 里的 beta 构建。
### 六渠道发布后验证
维护者和验证同学可按发版质量保障 SOP,对 curl、PowerShell、npm stable、npm beta、Homebrew、`dws upgrade` 执行安装与冒烟验证:
```bash
git clone https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli.git /tmp/dws-verify
cd /tmp/dws-verify/verify
bash verify-all-channels.sh
```
脚本使用隔离目录,不会替换当前 PATH 中的 `dws`;输出 `PASS`、`FAIL`、`SKIP` 汇总。跨平台渠道必须由对应平台补测,`SKIP` 不计为通过。验证范围和平台矩阵见 [`verify/README.md`](verify/README.md)。
<details>
<summary><strong>工作原理</strong></summary>
@@ -120,8 +214,9 @@ dws upgrade -y # 跳过确认直接升级
| Flag | 说明 |
|------|------|
| `--check` | 仅检查更新,不安装 |
| `--list` | 列出所有可用版本及更新日志 |
| `--version` | 升级到指定版本(如 `v1.0.7`) |
| `--list` | 列出正式 release 版本及更新日志 |
| `--beta` | 对 `upgrade`、`--check`、`--list` 使用 beta 预发布轨道 |
| `--version` | 升级到指定版本(如 `v1.0.7` 或 `v1.0.8-beta.1`) |
| `--rollback` | 回滚到上一个备份版本 |
| `--force` | 强制重新安装,即使已是最新版本 |
| `--skip-skills` | 跳过技能包更新 |
@@ -182,6 +277,52 @@ dws auth login --client-id <your-app-key> --client-secret <your-app-secret>
</details>
<details>
<summary><strong>多组织(profile)</strong></summary>
`dws` 可以同时登录多个钉钉组织。一个组织就是一个 **profile**,当前 profile 决定本次命令操作哪个组织(凭证按组织分别存储)。
```bash
dws auth login # 再登录一个组织 → 新增一个 profile(首次登录的为主组织)
dws profile list # 列出已登录组织(主 / 当前标记、状态)
dws profile switch <名称|corpId> # 切换默认组织(用 - 切回上一个)
dws --profile <名称|corpId> contact user search --query "..." # 单次对指定组织执行,不改默认组织
```
跨组织读取由 agent 编排,而非内置 `--all-orgs`:先 `dws profile list` 拿到组织,再对每个组织带 `--profile` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
macOS 下,如果已登记的 token slot 无法解密,为避免把系统 Keychain 和 file-DEK 写成混合状态,新的 OAuth 登录会直接拒绝。如果普通终端仍能读取登录态、只有设置 `DWS_DISABLE_KEYCHAIN=1` 的沙箱读不到,可在不暴露 token 的情况下迁移 legacy 与各 profile 的认证条目:
```bash
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --dry-run --format json
env -u DWS_DISABLE_KEYCHAIN dws auth migrate-keychain --to file-dek --yes --format json
DWS_DISABLE_KEYCHAIN=1 dws auth status --format json
```
迁移会先验证全部认证密文再写入、忽略无关的应用密钥;提交中断后可安全重跑。如果预检确认是密文本身损坏,报错会给出对应 `corpId`;只清理这个组织可执行 `dws auth logout --profile <名称|corpId>`,再重新登录。只有确认要丢弃全部本地 profile 时才用 `dws auth reset`。
</details>
<details>
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
仅拷贝 `~/.dws/app.json` 无法带走 refresh token;access token 约 2 小时后会失效。请使用官方导出/导入:
```bash
# A 沙箱(已登录)
dws auth export -o /tmp/dws-auth.tar.gz
# 或便于分片复制:dws auth export --base64 -o /tmp/dws-auth.b64
# B 沙箱
dws auth import -i /tmp/dws-auth.tar.gz
# 或:dws auth import -i /tmp/dws-auth.b64 --base64
dws auth status # 确认 Refresh Token: 有效
```
包内包含 `~/.local/share/dws-cli` 加密 keychain(含 `auth-token.enc` 与 `dek`)及 `~/.dws` 必要配置。
</details>
## 快速开始
```bash
@@ -213,43 +354,85 @@ dws contact user search --query "张三" --dry-run
dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserName, dept: .depts[0].deptName, userId}'
```
### Schema 发现
### 命令帮助与 Schema
Agent 无需预置所有命令知识,通过 `dws schema` 动态发现可用能力:
命令帮助和 Schema 分别负责命令契约的不同部分:
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
```bash
# 第一步:发现所有可用产品
dws schema --jq '.products[] | {id, tool_count: (.tools | length)}'
# 确认命令存在并查看当前接受的 flags
dws aitable record query --help
# 第二步:查看目标工具的参数结构
dws schema aitable.query_records --jq '.tool.parameters'
# 先在产品内发现命令,再查看选中 leaf 的契约
dws schema aitable
dws schema "aitable record query"
# 第三步:构造正确的调用
# 执行真实业务查询
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
```
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
### Agent Skills
仓库内置完整的 Agent Skill 体系(`skills/`),安装后 Claude Code / Cursor 等 AI 工具可通过自然语言直接操作钉钉:
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
Schema 生成共享的 reviewed 输入单独位于 `internal/cli/schema_hints/`。它们不是 Agent Skill,也不会进入二进制或发布 skill 包。
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
```bash
# 安装 skills 到当前项目
# 安装 skills 到当前项目(默认 mono)
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
```
> `install.sh` 安装到 `$HOME/.agents/skills/dws`(全局);`install-skills.sh` 安装到 `./.agents/skills/dws`(当前项目)。
>
> 国内用户加 `DWS_GITEE_REPO` 走 Gitee 镜像,见 [国内加速安装](#国内加速安装)。
**包含内容:**
**用 `dws skill setup` 切换或重装:**
```bash
# 交互式:提示选模式 + 目标 Agent
dws skill setup
# 把 mono skill 铺到所有检测到的 Agent home(claude / cursor / codex / opencode / qoder)
dws skill setup --mode mono --target all --yes
# 只装到某一个 Agent home
dws skill setup --mode multi --target cursor --yes
# 指定本地源目录(比如 fork 或正在改的版本)
DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
```
| 参数 | 取值 | 说明 |
|------|------|------|
| `--mode` | `mono` \| `multi` | skill 布局,不指定则交互式询问 |
| `--target` | `all` \| `claude` \| `cursor` \| `codex` \| `opencode` \| `qoder` | 安装目标,`all` 表示铺到所有检测到的 Agent home |
| `--source` | 路径 | 本地源目录(覆盖内置 skills) |
| `--yes` | — | 跳过确认提示 |
环境变量:`DWS_SKILL_MODE=mono|multi`(`install.sh` / `install.ps1` 也认)、`DWS_SKILL_SOURCE=<路径>`。
**包含内容(mono 布局):**
| 组件 | 路径 | 说明 |
|------|------|------|
| 主 Skill | `SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
| 产品参考 | `references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
| 意图指南 | `references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `scripts/*.py` | 13 个批量操作脚本(见下方) |
| 主 Skill | `skills/mono/SKILL.md` | 意图路由、决策树、安全规则、错误处理 |
| 产品参考 | `skills/mono/references/products/*.md` | 各产品命令详细参考(aitable、chat、calendar 等) |
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
<details>
<summary><strong>现成脚本</strong> — 13 个 Python 脚本,覆盖常见多步工作流</summary>
@@ -276,6 +459,51 @@ curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace
## 功能特性
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
> **前置条件**:先运行 `dws auth login`。个人身份从 OAuth token 解析,不允许通过命令行伪造。
只需要 event 能力时,可以使用官方便捷安装脚本:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
```
```bash
# 查看公开个人事件目录和 schema
dws event list
dws event schema user_im_message_receive_o2o
# 监听当前用户被 @ 的消息
dws event consume user_im_message_receive_at -f ndjson
# 监听与指定用户的单聊消息
dws event consume user_im_message_receive_o2o --user <userId> -f ndjson
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> -f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
dws event stop <subscribe_id>
```
| 特性 | 说明 |
|------|------|
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
</details>
<details>
<summary><strong>Raw API 调用</strong> — 直接调用钉钉 OpenAPI</summary>
@@ -357,7 +585,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
```bash
# 内置 jq 表达式
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
dws schema --jq '.products[] | {id, tools: (.tools | length)}'
dws schema "dev app create" --jq '.tool.required'
# 只返回指定字段
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
@@ -366,13 +594,13 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
</details>
<details>
<summary><strong>Schema 自省</strong> — 调用前查询任意工具的参数结构</summary>
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
```bash
dws schema # 列出所有产品和工具
dws schema aitable.query_records # 查看参数 Schema
dws schema aitable.query_records --jq '.tool.required' # 查看必填字段
dws schema --jq '.products[].id' # 提取所有产品 ID
dws schema aitable # 发现产品命令
dws schema "aitable record query" # 查看选中 leaf 契约
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
dws schema --all # CI/审计/基线的全量导出
```
</details>
@@ -394,36 +622,60 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
--title "周报" --text @-
```
> **说明**:`@` 仅在其后是 ASCII 路径前缀字符(`A-Z` / `a-z` / `0-9` / `.` / `/` / `~` / `_` / `-`)或 `@-`(stdin)时,才会被识别为 `@<path>` 文件注入语法。`--text "@所有人 周报"` / `--text "@张三 看一下"` 这类机器人消息中的字面 `@` 提及会原样透传到 API。
</details>
## 钉钉机器人 —— 把机器人接到你本地的 AI
`dws dev connect` 把一个钉钉机器人接到本地 AI CLI(Claude Code / Codex / opencode / Qoder / Gemini,或用 `--agent-cmd` 接任意工具):群里 @ 机器人提问,它用你本地的 agent 回答,按会话保留多轮上下文。
```bash
dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <secret>
```
聊天里的**会话指令**(整条消息就是指令时生效,不消耗一次 AI 调用):
| 指令 | 作用 |
|------|------|
| `/new`(别名 `/start`、`/reset`) | 开启新会话;旧会话保留(agent 支持的话仍可回溯) |
| `/clear` | 清空当前会话 —— 调 agent 真实会话原语真删(opencode 走 `DELETE /session/:id`);驱动接口没有删除原语的渠道退化为重置 |
完整四步教程见 [`docs/robot-quickstart.md`](./docs/robot-quickstart.md)(装工具 → 建机器人 → 接上 AI → 拉进群)。
## 核心服务
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 6 | `user` `dept` | 按姓名/手机号搜索、批量查询、部门树、当前用户信息 |
| 群聊 | `chat`(别名 `im`)| 23 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` | 消息(发送 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索)、群增删改 + 成员管理(含 `add-bot`)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊 |
| 日历 | `calendar` | 14 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 6 | `task` | 创建、列表、修改、完成、详情、删除 |
| 审批 | `oa` | 9 | `approval` | 同意 / 拒绝 / 撤销、待我审批 / 我发起的、流程列表、操作记录 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
| 日志 | `report` | 7 | `create` `list` `detail` `template` `stats` `sent` | 创建日志、收发列表、模版、详情、统计 |
| AI 表格 | `aitable` | 41 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件;模板 |
| 文档 | `doc` | 21 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
| 钉盘 | `drive` | 6 | `list` `info` `download` `mkdir` `upload-info` `commit` | 钉盘文件操作:列表、详情、下载、创建文件夹、两阶段上传 |
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
| 邮箱 | `mail` | 4 | `mailbox` `message` | 邮箱地址列表、KQL 邮件搜索、邮件详情、发送邮件 |
| 开发者文档 | `devdoc` | 1 | `article` | 搜索钉钉开放平台文档 |
| Raw API | `api` | 1 | — | 直接调用任意钉钉 OpenAPI(api / oapi 双形态),自动管理应用级 Token |
| 服务 | 命令 | 能力 |
|------|------|------|
| 通讯录 | `contact` | 按姓名 / 手机号 / 工号查人,部门、角色标签、花名册与离职 |
| 群聊 | `chat`(`im`)| 发送 / 回复 / 搜索消息,群与成员管理,机器人与 Webhook 发消息,表情反应,撤回 |
| 日历 | `calendar` | 日程 CRUD、参与者、会议室、闲忙与时间建议 |
| 待办 | `todo` | 创建 / 列表 / 修改 / 完成待办及评论 |
| 审批 | `oa` | 同意 / 拒绝 / 撤销 / 转交,查待办 / 已发起 / 抄送及表单 |
| 考勤 | `attendance` | 打卡记录、排班、考勤摘要、考勤组规则(只读) |
| DING | `ding` | 发送 / 撤回 DING 消息 |
| 日志 | `report` | 创建 / 提交日志,收发件箱,模版,统计 |
| AI 表格 | `aitable` | Base / 数据表 / 记录 / 字段 / 视图,权限与角色,自动化,图表与仪表盘,导入导出 |
| 文档 | `doc` | 搜索 / 读写文档,块级编辑,评论,权限,媒体,上传 / 下载 |
| 钉盘 | `drive` | 列表 / 搜索 / 下载,文件夹,上传,复制 / 移动 / 重命名,权限 |
| AI 听记 | `minutes` | 听记列表、摘要 / 关键词 / 转写 / 待办、思维导图、发言人、标签 |
| 邮箱 | `mail` | 邮箱、KQL 搜索、读 / 发、草稿、文件夹、模版、联系人 |
| 在线电子表格 | `sheet` | 在线表格:工作表与区域读写、筛选、条件格式、图片、CSV |
| 知识库 | `wiki` | 知识库:空间、成员、节点树、文档与文件 |
| 开发者文档 | `devdoc` | 搜索开放平台文档并排查 API 错误 |
| AI 搜问 | `aisearch` | 企业人员搜索:按姓名 / 部门 / 角色 / 职责 / 上下级 / 手机号 / 工号 |
| 直播 | `live` | 查看我的直播列表 |
| Raw API | `api` | 直接调用任意钉钉 OpenAPI,自动管理应用级 Token |
> **14 个产品,163 条命令。** 完整命令清单(带描述与使用场景):[`docs/command-index.md`](./docs/command-index.md)。运行 `dws --help` 查看顶层命令树,或 `dws <service> --help` 查看子命令。
> 完整命令清单(带描述与使用场景):[`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`(视频会议)
- 多 skill 模式(实验中)— 每产品一个独立 skill,位于 `skills/multi/`,通过 `dws skill setup --mode multi` 启用
</details>
@@ -476,7 +728,8 @@ dws chat message send-by-bot --robot-code BOT_CODE --group GROUP_ID \
- [命令索引](./docs/command-index.md) — 全部运行时命令,带描述与使用场景
- [参考手册](./docs/reference.md) — 环境变量、退出码、输出格式、Shell 补全
- [架构设计](./docs/architecture.md) — 发现驱动管道、IR、Transport 层
- [架构设计](./docs/architecture.md) — 静态端点管道、命令面、Transport 层
- [开放平台应用指令设计](./docs/dev-yulan-command-routing.md) — yulan dev app 应用侧命令、MCP overlay、权限流程与 Agent 路由
- [更新日志](./CHANGELOG.md) — 版本历史与迁移说明
## 贡献指南
+63
View File
@@ -0,0 +1,63 @@
class __CLASS_NAME__ < Formula
desc "__DESCRIPTION__"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "__VERSION__"
license "Apache-2.0"
__KEG_ONLY_LINE__
on_macos do
if Hardware::CPU.arm?
url "__DARWIN_ARM64_URL__"
sha256 "__DARWIN_ARM64_SHA256__"
else
url "__DARWIN_AMD64_URL__"
sha256 "__DARWIN_AMD64_SHA256__"
end
end
on_linux do
if Hardware::CPU.arm?
url "__LINUX_ARM64_URL__"
sha256 "__LINUX_ARM64_SHA256__"
else
url "__LINUX_AMD64_URL__"
sha256 "__LINUX_AMD64_SHA256__"
end
end
resource "skills" do
url "__SKILLS_URL__"
sha256 "__SKILLS_SHA256__"
end
def install
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
bin.install binary => "dws"
%w[LICENSE NOTICE README.md CHANGELOG.md].each do |name|
source = File.join(root, name)
pkgshare.install source if File.exist?(source)
end
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
cp_r(Dir["*"], skill_dest)
end
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
__CHANNEL_CAVEAT__
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/dws version")
end
end
+7 -36
View File
@@ -1,5 +1,5 @@
class __CLASS_NAME__ < Formula
desc "DingTalk Workspace CLI"
desc "Install locally built DingTalk workspace CLI artifacts for verification"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
url "__ARCHIVE_URL__"
sha256 "__ARCHIVE_SHA256__"
@@ -12,8 +12,6 @@ __KEG_ONLY_LINE__
end
def install
require "fileutils"
root = Dir["dws-*"].find { |entry| File.directory?(entry) } || "."
binary = File.join(root, "dws")
raise "binary not found: #{binary}" unless File.exist?(binary)
@@ -28,42 +26,15 @@ __KEG_ONLY_LINE__
skill_dest = pkgshare/"skills/dws"
skill_dest.mkpath
resource("skills").stage do
FileUtils.cp_r(Dir["*"], skill_dest)
cp_r(Dir["*"], skill_dest)
end
end
def post_install
require "fileutils"
skill_root = pkgshare/"skills/dws"
entries = Dir["#{skill_root}/*"]
return if entries.empty?
targets = [
Pathname.new(File.join(Dir.home, ".agents/skills/dws")),
Pathname.new(File.join(Dir.home, ".claude/skills/dws")),
Pathname.new(File.join(Dir.home, ".cursor/skills/dws")),
Pathname.new(File.join(Dir.home, ".gemini/skills/dws")),
Pathname.new(File.join(Dir.home, ".codex/skills/dws")),
Pathname.new(File.join(Dir.home, ".github/skills/dws")),
Pathname.new(File.join(Dir.home, ".windsurf/skills/dws")),
Pathname.new(File.join(Dir.home, ".augment/skills/dws")),
Pathname.new(File.join(Dir.home, ".cline/skills/dws")),
Pathname.new(File.join(Dir.home, ".amp/skills/dws")),
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|
parent_gate = dest.parent.parent
next if index > 0 && !parent_gate.directory?
FileUtils.rm_rf(dest)
FileUtils.mkdir_p(dest)
FileUtils.cp_r(entries, dest)
end
def caveats
<<~EOS
Agent Skills are bundled in #{pkgshare}/skills/dws.
Run `dws skill setup` to install them into your Agent directories.
EOS
end
test do
+38 -3
View File
@@ -12,6 +12,8 @@ const AGENT_DIRS = [
".agents/skills",
".claude/skills",
".cursor/skills",
".qoder/skills",
".qoderwork/skills",
".gemini/skills",
".codex/skills",
".github/skills",
@@ -136,11 +138,36 @@ function installSkillsToHomes(skillRoot) {
}
}
// cacheUserSkills copies the mono and multi trees out of the freshly extracted
// dws-skills.zip into ~/.dws/skills/{mono,multi}/ so that `dws skill setup`
// can fall back to a user-local cache when --source is not provided. mono is
// already installed into agent homes by installSkillsToHomes; the cache is
// purely a source-of-truth for the setup command.
function cacheUserSkills(extractedSkillsRoot) {
const cacheBase = path.join(os.homedir(), ".dws", "skills");
const monoSource = fs.existsSync(path.join(extractedSkillsRoot, "mono", "SKILL.md"))
? path.join(extractedSkillsRoot, "mono")
: extractedSkillsRoot;
const monoCache = path.join(cacheBase, "mono");
fs.rmSync(monoCache, { recursive: true, force: true });
copyChildren(monoSource, monoCache);
const multiSource = path.join(extractedSkillsRoot, "multi");
if (fs.existsSync(multiSource) && fs.statSync(multiSource).isDirectory()) {
const multiCache = path.join(cacheBase, "multi");
fs.rmSync(multiCache, { recursive: true, force: true });
copyChildren(multiSource, multiCache);
}
}
function main() {
const packageRoot = __dirname;
const assetsDir = path.join(packageRoot, "assets");
const vendorDir = path.join(packageRoot, "vendor");
const skillDir = path.join(packageRoot, "share", "skills", "dws");
// Extract dws-skills.zip into a staging directory so we can split mono/
// (installed to agent homes) from multi/ (cached for later setup use).
const skillsStaging = path.join(packageRoot, "share", "skills");
const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`];
if (!assetName) {
throw new Error(`unsupported platform: ${process.platform}/${process.arch}`);
@@ -156,8 +183,16 @@ function main() {
}
extractArchive(archivePath, vendorDir);
extractSkills(skillsPath, skillDir);
installSkillsToHomes(skillDir);
extractSkills(skillsPath, skillsStaging);
// For backward compatibility, the zip root carries a copy of mono content
// (SKILL.md + references/ + scripts/). Prefer the explicit mono/ subdir
// when present; fall back to the staging root otherwise.
const monoRoot = fs.existsSync(path.join(skillsStaging, "mono", "SKILL.md"))
? path.join(skillsStaging, "mono")
: skillsStaging;
installSkillsToHomes(monoRoot);
cacheUserSkills(skillsStaging);
}
main();
+93
View File
@@ -0,0 +1,93 @@
# Agent identification (agent_code & agentId)
dws tags every MCP request with **which agent host is driving it** and a
**per-instance id**, so usage can be sliced by channel/instance in the data
warehouse. This page is the integration contract.
## What dws sends on the wire
| Header | Meaning | Granularity |
|--------|---------|-------------|
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom if explicitly declared …) | channel |
| `x-dws-agent-instance-id` | `dwsa_<base62>` derived from `machineId + agent_code` | machine × channel |
| `x-dws-agent-id` | stable per-install machine id (v1-compatible) | machine |
| `X-Cli-Version` | dws CLI version (segments old vs new clients) | — |
`x-dws-agent-id` keeps its original machine-level meaning for backward
compatibility; `x-dws-agent-instance-id` is the new per-channel value. Old
clients send no `agent_code` / instance id — treat their absence as
"legacy/unknown", not an error.
## How `agent_code` is resolved (confidence ladder)
1. **T0 — explicit declaration:** `DINGTALK_DWS_AGENTCODE=<code>`. **Use this.**
2. **T1 — verified env signature:** an agent that auto-sets a distinctive var
(`CLAUDECODE`, `CODEX_SANDBOX`, `OPENCLAW_BUNDLE_ROOT`, `HERMES_HOME`).
3. **T2 — `VSCODE_BRAND`:** every VS Code fork declares its brand — one rule
covers Cursor / Windsurf / Trae / Qoder / Kiro / … incl. future forks.
4. **T3 — macOS `__CFBundleIdentifier`:** known agent app bundles.
5. **T4 — unresolved:** unknown host sends no agent_code. Never guessed.
## Declaring your agent (recommended — the only fully-general path)
Auto-detection cannot cover every agent: most terminal agents (gemini/
antigravity, aider, opencode, qwen-code, crush, goose, kimi, amazon-q,
continue, …) expose **no reliable self-identifying env var** — only user-set
API keys, which must not be used as identity. The robust answer is: **the host
sets `DINGTALK_DWS_AGENTCODE` in the env block where it launches dws as an MCP
server.** This is accurate for any agent, on any OS, and is future-proof.
MCP server config example (JSON-style hosts):
```jsonc
{
"mcpServers": {
"dingtalk-workspace": {
"command": "dws",
"args": ["mcp", "..."],
"env": { "DINGTALK_DWS_AGENTCODE": "your-agent-code" }
}
}
}
```
### Canonical codes
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
Use a stable slug. Values declared via `DINGTALK_DWS_AGENTCODE` are forwarded
verbatim so PAT grants and follow-up command checks use the same key.
## Trust & limitations — READ THIS
**`agent_code` AND the ids (`x-dws-agent-id`, `x-dws-agent-instance-id`) are
self-reported, best-effort signals, NOT an authenticated identity.**
- `agent_code`: every declaration/auto-detect signal is an env var the
host/user controls — spoofable (`export CLAUDECODE=1` → dws reports
`claudecode`).
- The ids are **even easier to forge**: they are generated, stored, and sent
entirely client-side. `machineId` is a random UUID in the plaintext
`~/.dws/identity.json` (which the user owns), and the instance id is just
`sha256(machineId + agent_code)`. Editing that one file — or rewriting the
header — lets anyone mint, split, rotate, or impersonate ids at will. The
`dwsa_` prefix does NOT make it a secure identifier.
- ✅ **Fit for statistics / observability** (the intended use): there is no
incentive to misreport one's own agent, and real hosts emit real signals, so
aggregate per-channel metrics are reliable in practice.
- ❌ **NOT fit for authentication, authorization, rate-limiting, billing, or
revocation.** Anything where a party benefits from lying must not trust this
field. For control-plane use you need a gateway-issued **authoritative**
agentId bound to a verified credential (clientId / PAT / OAuth) — a separate,
heavier mechanism, deliberately out of scope here.
Treat `agent_code` / `x-dws-agent-instance-id` as analytics dimensions only.
## Gateway side (required for the data to land)
dws sending the headers is necessary but not sufficient. The gateway must:
1. add `x-dingtalk-dws-agent-code`, `x-dws-agent-instance-id`, `X-Cli-Version`
to the upstream-header pass-through allowlist (otherwise they are stripped);
2. log them as fields, and deliver them to the warehouse (alongside the
existing flow-control / execution logs).
+36 -17
View File
@@ -1,26 +1,45 @@
# Architecture
`dws` is a Go CLI that turns DingTalk MCP metadata into a command-line surface for both humans and AI agents.
`dws` is a Go CLI with a versioned, static command surface for DingTalk MCP capabilities. Cobra help serves humans; the embedded Command Catalog serves AI agents.
## High-Level Flow
1. `internal/market` fetches the registry and server metadata.
2. `internal/discovery` resolves runtime server capabilities and caches results.
3. `internal/ir` normalizes discovery output into one canonical tool catalog.
4. `internal/cli` and `internal/app` mount that catalog into the public Cobra command tree.
5. `internal/transport` executes MCP JSON-RPC calls and `internal/output` formats responses.
1. `cmd` is the CLI entrypoint, invoking `internal/app` to build the root Cobra command tree.
2. `internal/app` wires static utility commands (`auth`, `audit`, `schema`, `completion`), product helpers, and versioned plugin descriptors.
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
6. Schema generation starts from the reviewed `CommandRegistry`, binds each identity to the exact current Cobra leaf, and then resolves typed constraints, sanitized MCP snapshots, Agent hints, and Skills into one `SchemaRegistry`. Startup and Schema queries do not call MCP `tools/list`.
7. The embedded Catalog is a downstream release artifact and never backfills identity or participates in regeneration. Stable flag-to-interface property bindings come from the reviewed, content-addressed v3 manifest in `schema_parameter_bindings.json`; its exact active tuples, corrections, removals, and mapping exclusions are validated against the final bound `SchemaRegistry`. CLI `required` and constraints come from the resolved typed contract, while MCP `required` remains interface-only metadata.
8. Agent selection results are fixed in versioned review inputs. Every public tool has explicit use/avoid/example and interface disposition metadata; Skill references that are not current leaves require an explicit alias/group/stale/out-of-surface review instead of fuzzy runtime matching.
## Repository Structure
- `cmd`: CLI entrypoint
- `internal/app`: root command wiring and static utility commands
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and execution
- `internal/ir`: canonical intermediate representation for discovered tools
- `internal/generator`: docs, schema, and skill generation pipeline
- `internal/compat`, `internal/helpers`: legacy-compatible overlays and helper commands
- `skills/`: bundled agent skills source and generated skill docs
- `test/`: CLI, compatibility, integration, contract, and script tests
## Public Repository Contract
This repository ships source, docs, tests, packaging templates, and install scripts. Generated or release-only artifacts are produced by repository scripts and are not required to exist in a clean checkout unless explicitly committed as part of a release workflow.
- `internal/app`: root command wiring, static utility commands, and plugin loading
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
- `internal/plugin`: versioned plugin manifest, hook, skill, and transport descriptor loading
- `internal/cli`: embedded Agent Command Catalog, static schema query, and catalog contracts
- `internal/generator`: deterministic Agent metadata and Command Catalog generators
- `internal/executor`: invocation dispatch and result handling
- `internal/transport`: MCP HTTP client and request signing
- `internal/auth`: login, token management, agent-code detection, identity
- `internal/audit`: user operation audit log (JSONL, hash chain, forwarding)
- `internal/errors`: structured error model with categories and hints
- `internal/keychain`: OS keychain integration for credential storage
- `internal/security`: endpoint allowlist and domain trust
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
- `internal/cobracmd`: shared Cobra command builders
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `internal/output`: response formatting (json, table, raw, pretty)
- `internal/logging`: structured logging and argument sanitization
- `internal/tui`: terminal UI helpers
- `internal/recovery`: panic recovery and graceful degradation
- `pkg/configmeta`: environment variable registry and documentation
- `pkg/config`: configuration constants and paths
- `pkg/edition`: edition detection (oss vs enterprise)
- `pkg/mcptypes`: MCP protocol type definitions
- `internal/syncdata`: generated static endpoint and command-routing data synced from the Wukong baseline
- `skills/`: bundled agent skills (mono/ and multi/ layouts)
- `test/`: CLI, integration, contract, unit, and skill E2E tests
- `scripts/`: install scripts, policy checks, and CI helpers
+44 -25
View File
@@ -13,40 +13,44 @@ repository root while preserving repo-local guidance for automation.
## Project Snapshot
- `dws` is a Go-based DingTalk Workspace CLI and MCP runtime bridge.
- One internal Tool IR drives canonical CLI, schema, docs, skills, and snapshots.
- Compatibility and helper surfaces are overlays, not the canonical truth.
- Product commands are loaded dynamically via `internal/plugin` from bundled descriptors.
- Command handlers live in `internal/helpers`; runtime execution flows through `internal/executor` and `internal/transport`.
## Repository Map
- `cmd`: public CLI entrypoint
- `internal/app`: root command wiring and command tree mount points
- `internal/discovery`, `internal/market`, `internal/transport`: runtime discovery and MCP transport
- `internal/generator`: CLI/schema/docs/skills generation pipeline
- `internal/compat`, `internal/helpers`: legacy-compatible aliases and helper commands
- `internal/app`: root command wiring, static utility commands, plugin loading
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
- `internal/plugin`: plugin-based dynamic command loader
- `internal/cli`: catalog types and static endpoint loader
- `internal/executor`: invocation dispatch and result handling
- `internal/transport`: MCP HTTP client and request signing
- `internal/auth`: login, token management, agent-code detection
- `internal/audit`: user operation audit log
- `internal/errors`: structured error model with categories and hints
- `internal/keychain`: OS keychain integration for credential storage
- `internal/security`: endpoint allowlist and domain trust
- `internal/pat`: PAT (Personal Access Token) authorization flow
- `docs/`: public architecture and reference docs
- `hack/`: developer-only helper commands not shipped as public binaries
- `scripts/`: build, test, lint, packaging, and policy checks
- `test/`: integration, contract, compatibility, and script validation suites
- `test/`: CLI, integration, contract, unit, and skill E2E test suites
## Task Routing
- Add or fix a command path: start from `internal/app` and the related module under `internal/*`
- Discovery or protocol issues: inspect `internal/discovery`, `internal/market`, `internal/transport`
- Generated output drift: inspect `internal/generator` and run drift checks
- Legacy behavior mismatch: inspect `internal/compat` and `test/cli_compat`
- Failure or degraded mode: inspect `internal/discovery`, `internal/errors`
- Add or fix a command path: start from `internal/helpers` (handler implementations) or `internal/app` (command tree wiring)
- Protocol or transport issues: inspect `internal/transport`
- Auth or login issues: inspect `internal/auth`, `internal/pat`, `internal/keychain`
- Error message or category issues: inspect `internal/errors`
- Audit log issues: inspect `internal/audit`
- Plugin loading or command surface: inspect `internal/plugin`
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
## Generated Artifacts
## Policy Checks
Prefer editing source logic instead of generated files directly.
When command surface or plugin descriptors change, run:
- Generated-heavy paths:
- `docs/generated/`
- `skills/generated/`
- `test/golden/generated_outputs/`
- When generator or command surface changes, run:
- `./scripts/policy/check-generated-drift.sh`
- `./scripts/policy/check-command-surface.sh --strict`
- `./scripts/policy/check-command-surface.sh --strict`
- `./scripts/policy/check-open-source-assets.sh`
## Common Commands
@@ -55,12 +59,27 @@ make build
make test
make lint
./scripts/dev/ci-local.sh
./scripts/policy/check-generated-drift.sh
./scripts/policy/check-command-surface.sh --strict
./scripts/policy/check-open-source-assets.sh
git diff --check
```
## Homebrew Formula PR Automation
Official tag releases require the repository Actions secret
`HOMEBREW_PR_TOKEN`. The `DingTalk-Real-AI` organization currently does not
allow fine-grained personal access tokens to target this repository, so use a
classic personal access token owned by a maintainer or release-bot account with
only the `public_repo` scope. Do not reuse a broad developer token.
Store the non-expiring token as the `HOMEBREW_PR_TOKEN` repository Actions
secret. Replace it immediately if it is exposed, its owner loses repository
access, or the release-bot ownership changes. The Release workflow uses this
dedicated token only to push an `automation/homebrew-*` branch and open the
stable or beta Formula PR. It does not push Formula changes directly to `main`.
No maintainer environment variable is required when creating a tag. Using the
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
Actions from creating pull requests, and its generated PR events may require
separate workflow approval.
## Handoff Checklist
Before handoff, include:
+119
View File
@@ -0,0 +1,119 @@
# Pull request quality gates
The repository defines five focused checks in addition to its existing CI:
- **Interface Integrity** enforces backwards compatibility. Every historical
command path and alias must still resolve, every historical command must
still render `-h`, and historical flags must keep their type and shorthand.
New commands, aliases, and flags are allowed. The same job compares the full
complete `dws schema --all` contract with the PR merge-base, blocking removed
products/tools/parameters, incompatible parameter or interface mappings,
constraint drift, and safety-semantic drift. It also checks that executable
`dws ...` references in `skills/**/*.md` resolve to real commands.
Help compatibility covers command/alias/flag spelling, flag type and
shorthand; descriptive prose may evolve without breaking the gate.
- **Coverage** runs unit tests on every pull request and prints both overall and
changed-code statement coverage. During the migration to the 80% repository
target, overall coverage may not regress from a profile generated from the
merge-base with the same test command, while changed production Go
statements must meet 80%. Linux, Windows, and macOS each generate a native
coverage profile for changed packages and enforce the threshold against
changed files buildable on that platform, so build-tagged source cannot be
hidden by an Ubuntu-only profile. Overall non-regression allows 0.1 percentage point of measurement
variance to avoid failing unchanged code on test-path noise. Set
`COVERAGE_ENFORCE_OVERALL=true` once repository coverage reaches 80% to make
the overall target fail closed as well.
- **CLI Smoke** builds the release binary, reads the root command list from the
structured Interface contract, and renders offline help for every public
top-level command. It rejects Cobra's unknown-command root-help fallback and
fails when the checked-in development fixture is stale.
- **Mock MCP Smoke** runs the existing HTTP and stdio MCP lifecycle tests
(`Initialize -> ListTools -> CallTool`).
- **AI Behavior Check** applies to pull requests labeled `ai-generated`. It
limits the change to 30 files and blocks release/CI infrastructure changes,
including policy implementations and the checked-in Interface fixture.
It uses `pull_request_target` without checking out PR code, so the policy
cannot be bypassed by changing the workflow in the same pull request. The
evaluator writes an `AI Behavior Check` commit status to the PR head SHA so
GitHub rulesets can require it.
## Running the compatibility gates
Run:
```sh
make build
make interface-integrity
make authoritative-interface-integrity BASE_REF=<merge-base>
make schema-compatibility BASE_REF=<merge-base>
make skill-command-integrity
make cli-smoke
# Run on the corresponding native runner with its generated profile:
make coverage-gate-platform BASE_REF=<merge-base> PROFILE=<coverage-profile>
```
`make coverage-gate` is the enforcement step, not a profile generator. It
expects the candidate, policy, and merge-base profiles (`coverage.txt`,
`coverage-policy.txt`, and `coverage-base.txt`) produced by the preceding CI
steps. A clean local checkout can reproduce the Linux/overall CI gate with:
```sh
base_ref=$(git merge-base HEAD origin/main)
root=$(pwd)
base_worktree=$(mktemp -d "${TMPDIR:-/tmp}/dws-coverage-base.XXXXXX")
rmdir "$base_worktree"
cleanup() { git worktree remove --force "$base_worktree" >/dev/null 2>&1 || true; }
trap cleanup EXIT HUP INT TERM
go test -count=1 -coverprofile=coverage.txt -covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
go test -count=1 -coverprofile=coverage-policy.txt -covermode=atomic \
./pkg/... ./scripts/policy/...
git worktree add --detach "$base_worktree" "$base_ref"
(
cd "$base_worktree"
go test -count=1 -coverprofile="$root/coverage-base.txt" -covermode=atomic \
./ ./cmd/... ./internal/... ./skills/...
)
make coverage-gate BASE_REF="$base_ref"
```
The native-platform target likewise expects `PROFILE` to have already been
generated on that operating system. CI owns those generation steps; copying
only either enforcement command into a clean checkout is intentionally an
incomplete invocation.
CI derives the authoritative Interface snapshots from both the PR merge-base
and the latest reachable stable release tag. The complete Schema snapshot comes
from the PR merge-base, which contains the registry-first Schema introduced on
`main`. The candidate branch cannot bless a breaking change by editing a
fixture. Schema additions are allowed; historical products, tools, parameters,
parameter mappings, positional execution fields, constraints, and safety
semantics remain protected. Positional descriptions are documentation and may
change without breaking compatibility.
`make update-interface-baseline` still extends the local checked-in Interface
fixture used by `make interface-integrity`. Updates are monotonic: they add new
commands and flags without removing history.
For an intentional compatibility reset at a major-version boundary, run
`make reset-interface-baseline`. This replaces all CLI compatibility history
with the current command tree and must receive explicit human review.
## Required GitHub repository settings
Create a ruleset for `main` that requires pull requests and code-owner review,
then mark these aggregate status checks as required:
- `CI Gate`
- `Multi Profile E2E`
- `AI Behavior Check`
`CI Gate` fails closed unless every first-layer CI job succeeds, including
lint, tests, native Linux/Windows/macOS coverage, policy,
Interface/Schema/Skill integrity, and smoke tests. Requiring the aggregate
check keeps repository rules stable when an internal job is renamed or split.
The `ai-generated` label must be applied by the PR-creation automation or by a
maintainer; GitHub cannot infer reliably whether a human-authored PR contains
AI-generated code.
+8 -9
View File
@@ -2,12 +2,11 @@
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Source**: `dws-wukong/envelope/channel/open/pre/config.json`
- **Products**: 13
- **Total commands**: 159
- **Generated from**: `internal/compat.BuildDynamicCommands` rendering of the pre config — the same code path the CLI uses at runtime.
- **Total commands**: 160
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Edit `pre/config.json`, not this file.
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
## Global flags
@@ -36,7 +35,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 1 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
@@ -148,8 +147,8 @@ _Group chats, conversations, messages, and robot/webhook integrations._
| `dws chat group members remove` | Remove one or more members from a group chat. | When the agent kicks users who should no longer have access to the group. |
| `dws chat group rename` | Update the display name of a group chat. | When the agent is rebranding or clarifying the purpose of an existing group. |
| `dws chat list-top-conversations` | Fetch the list of conversations the current user has pinned to the top of their chat list. | When the agent needs to prioritize the user's most important conversations in a summary or dashboard. |
| `dws chat message list` | Pull the recent message history of a specific conversation (v2), paginated. | When the agent needs to read what has recently been said in a conversation to summarize or reason about it. |
| `dws chat message list-all` | Search all messages across the current user's conversations within a time range. | When the agent needs to audit or summarize everything the user saw across chats in a window. |
| `dws chat message list` | Pull the recent message history of a specific conversation, including quoted-message context for merged forwards and images. | When the agent needs to read what has recently been said in a conversation and retain the context of replies. |
| `dws chat message list-all` | Search all messages across the current user's conversations within a time range, surfacing any search-entitlement guidance. | When the agent needs to audit or summarize everything the user saw across chats in a window. |
| `dws chat message list-by-sender` | Fetch messages authored by a specific sender across both single and group chats. | When the agent needs to pull everything a particular colleague said recently. |
| `dws chat message list-focused` | Fetch messages from users the current user has marked as "special focus" (starred contacts). | When the agent builds a priority-inbox view highlighting messages from important people. |
| `dws chat message list-mentions` | Fetch messages where the current user was @-mentioned. | When the agent wants to surface items that explicitly require the user's attention. |
@@ -182,11 +181,12 @@ _Users, departments, and directory lookups._
_Search the DingTalk Open Platform documentation._
**1 commands**
**2 commands**
| Command | Description | When to use |
|---|---|---|
| `dws devdoc article search` | Search the DingTalk Open Platform documentation by keyword. | When the agent needs authoritative API reference or guides to answer a developer question. |
| `dws devdoc error diagnose` | Troubleshoot an Open Platform API failure by requestId, traceId, error code, error message, or context. | When the agent has a requestId, traceId, error code, or failure description and needs diagnostic facts plus references. |
## `dws ding` — DING Messages
@@ -320,4 +320,3 @@ _Personal todo task management._
| `dws todo task get` | Retrieve the full details of a todo item by ID. | When the agent inspects a specific todo's content, due date, and executors. |
| `dws todo task list` | List todos for the current user within the current organization. | When the agent surfaces the user's outstanding tasks or builds a daily focus list. |
| `dws todo task update` | Update a todo's title, description, due time, or executors. | When the agent edits an existing todo after new information comes in. |
+148
View File
@@ -0,0 +1,148 @@
# Running the connector as a 7x24 service
`dws dev connect` keeps a DingTalk robot wired to a local agent over a
Stream long-connection. By default it runs in the foreground and dies when the
terminal closes. For an unattended "digital employee" you have two options.
> **Security**: prefer `--unified-app-id <uappid>` over
> `--robot-client-id/--robot-client-secret`. With `--unified-app-id` the CLI
> resolves clientId/clientSecret at runtime through `dev app credentials get`,
> so the secret never appears in `ps` / journald / shell history. Pasting
> `--robot-client-secret` onto argv lets any local user read your AppSecret
> with `ps -ef`; the CLI will warn you when you do that.
## Option A: built-in daemon (recommended for a quick start)
```bash
# Detach into a background supervisor that restarts the connector if it crashes.
dws dev connect --daemon \
--channel claudecode \
--unified-app-id <unifiedAppId>
# Inspect / stop / restart it (locate the daemon by unifiedAppId).
dws dev connect status --unified-app-id <unifiedAppId>
dws dev connect stop --unified-app-id <unifiedAppId>
dws dev connect restart --unified-app-id <unifiedAppId>
```
- The parent prints the daemon pid and the log path, then exits.
- A supervisor process (POSIX `setsid`, detached from the terminal) keeps a
worker connector alive, restarting it with exponential backoff (1s..60s, up to
10 consecutive fast failures) when it exits abnormally.
- The single-instance lock (one connector per robot per machine) is reused, so a
duplicate daemon refuses to start.
- Logs go to `~/.dws/connect/<key>/daemon.log` with size-based rotation
(5 MB x 2 backups), and the pid file lives at
`~/.dws/connect/<key>/daemon.pid`.
- The daemon does NOT survive a reboot. For that, use Option B.
> Windows: `--daemon` is not supported (no `setsid` / POSIX signal stop). Use a
> Windows service wrapper around the foreground command instead.
## Option B: OS service manager (survives reboot)
Use the foreground command (NOT `--daemon`) and let the OS supervise and
restart it. This is the most robust way to get boot-time auto-start.
### macOS — launchd
Save as `~/Library/LaunchAgents/com.dingtalk.dws.connect.plist`, edit the paths
and `REPLACE_UNIFIED_APP_ID`, then `launchctl load -w <path>`.
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.dingtalk.dws.connect</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/dws</string>
<string>dev</string>
<string>connect</string>
<string>--channel</string>
<string>claudecode</string>
<string>--unified-app-id</string>
<string>REPLACE_UNIFIED_APP_ID</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>10</integer>
<key>StandardOutPath</key>
<string>/tmp/dws-connect.out.log</string>
<key>StandardErrorPath</key>
<string>/tmp/dws-connect.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
```
`KeepAlive=true` makes launchd restart the connector if it exits; the connector
itself relies on the single-instance lock to avoid duplicates.
### Linux — systemd (user service)
Save as `~/.config/systemd/user/dws-connect.service`, edit paths and
`REPLACE_UNIFIED_APP_ID`, then:
```bash
systemctl --user daemon-reload
systemctl --user enable --now dws-connect.service
# allow it to keep running after logout:
loginctl enable-linger "$USER"
```
```ini
[Unit]
Description=DWS DingTalk robot connector
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/dws dev connect \
--channel claudecode \
--unified-app-id REPLACE_UNIFIED_APP_ID
Restart=always
RestartSec=5
# Optional hardening:
# NoNewPrivileges=true
# PrivateTmp=true
[Install]
WantedBy=default.target
```
`Restart=always` + `RestartSec` gives crash recovery; systemd captures stdout/
stderr into the journal (`journalctl --user -u dws-connect -f`).
## Legacy: passing clientId/clientSecret directly (not recommended)
If you truly must pass credentials on the command line (e.g. one-off local
debugging without a unifiedAppId), the CLI still accepts
`--robot-client-id <id> --robot-client-secret <secret>` and will print a
security warning to stderr. This form:
- exposes `clientSecret` to every user on the box via `ps -ef`;
- gets baked into launchd `ProgramArguments` / systemd `ExecStart`, which
makes rotation harder;
- means `dws dev connect restart` cannot re-fetch credentials — you
must re-run the full command yourself.
Prefer `--unified-app-id`. Only fall back to the pair when you understand the
trade-off.
## Which to choose
- Just need it to outlive the terminal and self-heal on crash → `--daemon`.
- Need it to come back after a reboot, with the OS owning the lifecycle → use
launchd / systemd with the foreground command.
+137
View File
@@ -0,0 +1,137 @@
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
## 手工评测流程
逐条执行,每条三步:
1. **发起**:在一个干净的 agent 会话里,把该条的「用户说」原样发给 agent(不给额外提示)。
2. **观察**:看 agent 选了哪些命令、什么 flag、做了哪些判断/追问。
3. **判分**:对照「通过判据」人工打分。复合用例含多个判据,**全部满足才记 PASS**;部分满足记 PASS\*(半通过)并在备注写清缺哪条。记一行 `用例# | PASS / PASS* / FAIL | 备注(错在哪)`。
> 「易错点」是常见扣分项,重点盯。建议每次技能改动后整套重跑,对比上次。
## 覆盖矩阵
| 用例 | 覆盖的子命令 | 横切行为 |
|------|-------------|---------|
| C1 建应用配齐基础 | app create / get / credentials get / update | dry-run/yes、定位符、密钥脱敏 |
| C2 列表与定位 | app list | cursor 分页、按名定位、多命中候选 |
| C3 生命周期 | app disable / enable / delete | 写后回读、appStatus、pretty 标签、confirm-name 防误删 |
| C4 网页应用到生效 | webapp get / config | 生效模型(改配置≠生效) |
| C5 版本发布全流程 | version create / list / get / check-approval / publish / status | 生效模型、审批人由用户拍板 |
| C6 权限全流程 | permission list / add / remove | 过滤分页、生效模型、批量聚合出参 |
| C7 成员与安全 | member list / add / remove、security config | 整组覆盖语义 |
| C8 机器人与建联 | robot submit / result / get / config / enable / disable、dev connect | 异步轮询、robot info not exist、建联依赖预检、长驻进程 |
| C9 事件与文档排查 | event list / subscribe / unsubscribe、dev doc search | 错误码透传、文档 RAG |
| C10 意图消歧 | (不进 dev,先澄清) | 泛词边界、转其它技能出口 |
---
## 用例
### C1. 新建应用并配齐基础
- **用户说**:「建一个内部应用叫 DemoApp,描述『内部测试』;建好后给我看看它的详情,把它的 AppKey/AppSecret 也取出来;对了名字再改成 DemoApp2。」
- **覆盖**:`app create` / `get` / `credentials get` / `update`;dry-run/yes、定位符、密钥脱敏。
- **期望(分步)**:
1. `app create --name DemoApp --desc 内部测试 --dry-run` → 给用户看 `invocation.params` 确认 → `--yes`,记下返回的 `unifiedAppId`。
2. `app get --unified-app-id <id> --format json` 看详情。
3. `credentials get --unified-app-id <id> --format json` 取凭证。
4. `app update --unified-app-id <id> --name DemoApp2 --dry-run` → `--yes`。
- **通过判据**:每个写操作先 dry-run 再 yes;全程用 `unifiedAppId` 定位;取凭证走 `credentials get`(不是 app get);`clientSecret/appSecret` 按敏感处理、不明文写进回答。
- **易错点**:不 dry-run 直接 yes;把 secret 打印给用户;用 `app get` 当取凭证。
### C2. 应用列表与按名定位
- **用户说**:「列出我们企业的开放平台应用,一页 20 条,有下一页继续翻;再帮我找名字叫『早晚会』的那个应用,看它详情。」
- **覆盖**:`app list`;cursor 分页、按名定位、多命中。
- **期望(分步)**:
1. `app list --page-size 20 --format json`;出参有 `nextCursor` 则续翻 `--cursor <上次 nextCursor>` 直到为空。
2. `app list --name 早晚会 --format json` 找 `unifiedAppId` → 唯一命中后 `app get --unified-app-id <id>`。
- **通过判据**:首次不传 `--cursor`,续翻原样回传 `nextCursor`,不自己构造/解析、不跨命令复用;用 list 过滤拿 id 再 get;多条命中时展示候选让用户选、不取第一条。
- **易错点**:用 `--page/--offset` 翻页;`app get --name xxx`(get 不接受 name 定位)。
### C3. 应用生命周期(停用 / 启用 / 删除)
- **用户说**:「先把 DemoApp2 停用,确认停好了告诉我;然后再启用回来;最后这个应用不要了,删掉。」
- **覆盖**:`app disable` / `enable` / `delete`;写后回读、appStatus、pretty、confirm-name。
- **期望(分步)**:
1. `disable --unified-app-id <id> --dry-run` → `--yes` → 回读 `app get`(可 `--format pretty` 看 `appStatusText`),确认 `appStatus=0` 才算停用完成。
2. `enable --dry-run` → `--yes` → 回读确认 `appStatus=1`。
3. 删除:先 `app get` 展示摘要 → `delete --dry-run` → 真删需 `--confirm-name <应用真实名>`(与定位到的名一致)+ `--yes`。
- **通过判据**:写成功 ≠ 状态已变,每步回读 appStatus(0停/1激活/2待激活/3过期);删除前展示摘要并让用户确认;confirm-name 匹配才删,读不到应用名时中止(fail-closed)。
- **易错点**:看到 success 就回报已停/已删不回读;不带 confirm-name 直接删。
### C4. 网页应用配置到生效
- **用户说**:「给这个应用配个钉钉里打开的移动端首页 https://example.com/m,配完要真正能用。」
- **覆盖**:`webapp config` / `get`;生效模型。
- **期望(分步)**:`webapp config --unified-app-id <id> --homepage-url https://example.com/m --dry-run` → `--yes` → `webapp get` 回读;明确说明「改配置 ≠ 线上生效,需走版本通道」:`version create → check-approval → publish`(详见 C5)。
- **通过判据**:先 dry-run 再 yes;配完回读 webapp get;主动点明需发版本才生效,不谎称「已生效」。
- **易错点**:配完直接说已生效,不提版本通道。
### C5. 版本发布全流程(含选审批人)
- **用户说**:「我刚改了配置,发个版本上线;先看下历史版本和这次要发的版本详情;需要审批的话我来选审批人。」
- **覆盖**:`version create` / `list` / `get` / `check-approval` / `publish` / `status`;生效模型、审批人由用户拍板。
- **期望(分步)**:
1. `version create --unified-app-id <id> --version <号> --desc <说明> --yes`,记 `versionId`(新应用 `version list` 空时先 create,不要误判无可发布)。
2. `version list` 看历史、`version get --version-id <id>` 看详情。
3. `version check-approval --version-id <id>`(预检,不发布,返回是否需审批 + 候选审批人)。
4. 把候选审批人列表给用户选 → `version publish --version-id <id> --approver <用户选的> --yes`(含高敏权限加 `--confirm-sensitive`)。
5. `version status --version-id <id>` 跟踪到 `versionStatus=RELEASE` 才算生效。
- **通过判据**:check-approval 不实际发布;审批人由用户拍板、agent 不默认取第一个;发布后回读 status 到 RELEASE。
- **易错点**:跳过 check-approval 直接 publish;agent 自己选审批人;version list 空就说没东西可发。
### C6. 权限全流程(查 / 申请 / 批量取消)
- **用户说**:「查下跟『机器人发消息』有关、还没开通的权限;开通其中合适的那个,要真正生效;再把另外两个不需要的权限点 A、B 一起取消掉。」
- **覆盖**:`permission list` / `add` / `remove`;过滤分页、生效模型、批量聚合。
- **期望(分步)**:
1. `permission list --unified-app-id <id> --keyword 机器人发消息 --status UNAUTHED --page-size 50` 找 `scopeValue`(150+ 时用 `nextCursor` 续翻)。
2. `permission add --permissions <scopeValue> --dry-run` → `--yes`;若 `requiredApproval=true`,走版本通道生效(接 C5)。
3. `permission remove --permissions A,B --dry-run` → `--yes`,读出参 `{results, ok, total, failedCount}` 逐条判断。
- **通过判据**:只传 `scopeValue`(不传 API/分组名);用 keyword+status 过滤、分页不漏;需审批的明确走版本;批量取消读 `ok/failedCount` 报告部分失败,不只看命令成功。
- **易错点**:把 API 名当权限点;add 后就说开通了;批量 remove 漏报部分失败。
### C7. 成员与安全配置
- **用户说**:「把 userId 张三、李四加成这个应用的开发者,加完看下成员列表,回头把李四移除;另外给应用加一个登录重定向地址 https://b.example.com/cb,别把原来的地址冲掉。」
- **覆盖**:`member list` / `add` / `remove`、`security config`;整组覆盖。
- **期望(分步)**:
1. `member add --unified-app-id <id> --user-ids 张三id,李四id --member-type DEVELOPER --dry-run` → `--yes` → `member list` 回读 → `member remove --user-ids 李四id --member-type DEVELOPER --dry-run` → `--yes`。
2. 安全配置:提醒 `--redirect-urls` 是**整组覆盖、不是追加**——要保留原地址需把旧+新一起传:`security config --redirect-urls <旧1,旧2,新> --dry-run` → `--yes`。
- **通过判据**:`--user-ids` 逗号分隔、`--member-type` 必填、用 userId 不用姓名;识别整组覆盖语义、避免只传新地址冲掉旧的;未提供的字段(如 ip-whitelist)不动。
- **易错点**:漏 `--member-type`;security 只传新 redirect-urls 把旧的清空。
### C8. 机器人建号、配置与本地建联
- **用户说**:「帮我建一个叫『小助手』的答疑机器人;另外这个现有应用还没机器人,给它也配上并启用;最后把机器人接到我本地的 Claude Code 调试。」
- **覆盖**:`robot submit` / `result` / `get` / `config` / `enable` / `disable`、`dev connect`;异步轮询、robot info not exist、建联依赖预检、长驻进程、密钥脱敏。
- **期望(分步)**:
1. 新建:`robot submit --name <应用名> --robot-name 小助手 --desc <功能> --dry-run` → `--yes`(拿 taskId)→ 按 `intervalSeconds` 轮询 `robot result --task-id <taskId>`,只有 `SUCCESS` 才用返回 `robotCode/clientId/clientSecret`(敏感)。
2. 现有应用:`robot get` 若 `robotStatus=UNCONFIGURED` → `robot config --unified-app-id <id> --name ... --mode STREAM --dry-run` → `--yes`(upsert 首次即创建)→ 回读 `robot get` 看 `robotStatus=ONLINE` → 需要时 `robot enable`(停用 `robot disable`)。
3. 建联:`dev connect --channel auto --unified-app-id UAID --dry-run` 看出参 `cli` 字段做依赖预检;正式 connect 是前台长驻进程,对话里跑要后台运行并告诉用户怎么停,或引导自己开终端。
- **通过判据**:走异步 submit/result(同步建号已下线),轮询到 SUCCESS 再用凭证;未配置时走 config 不是 enable;config 是 upsert;写后回读 `robotStatus`;建联先 dry-run 预检、处理好长驻/缺凭证(先 submit/result 建号);默认用 `--unified-app-id` 建联而不是把 clientSecret 明文拼进命令行(避免被 `ps` 拉到)。
- **易错点**:找「同步一次建好」的命令;WAITING 就用凭证;robot info not exist 时去 enable;前台直接起 connect 卡住对话;把 clientSecret 直接怼到命令行上。
### C9. 事件订阅与上游错误排查
- **用户说**:「让这个应用订阅『群成员入群』事件,订阅完看下当前订阅了哪些,再把它取消掉;对了我之前发版本报了个 errcode 62012,这是啥意思?」
- **覆盖**:`event list` / `subscribe` / `unsubscribe`、`dev doc search`;错误码透传、文档 RAG。
- **期望(分步)**:
1. `event list --unified-app-id <id> --page-size 20 --format json` 取 `eventCode` → `event subscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes` → `event list` 回读 → `event unsubscribe --unified-app-id <id> --event-codes chat_add_member_org --dry-run` → `--yes`。事件码不确定先 `event list` 翻页查。
2. 错误码:业务错误 `ServiceResult.success=false` 原样透传 `errorCode/errorMsg`,再 `dev doc search --keyword "errcode 62012 <message>" --format json` 做官方文档 RAG,结论基于命中条目。
- **通过判据**:`--event-codes` 逗号分隔,写操作先 dry-run;`event list` 使用 `hasMore/nextCursor` 翻页;不编造事件码/错误含义;先透传原始错误再走 RAG,结论不臆测、不编不存在的命令。
- **易错点**:编事件码;把事件回调地址塞进事件订阅命令;凭空解释错误码。
### C10. 意图消歧(泛词边界)
- **用户说**:「帮我建个机器人。」(无任何开放平台上下文)
- **覆盖**:泛词消歧、边界与角色。
- **期望**:`应用`/`机器人` 是泛词——先追问确认是不是开发者后台的「企业内部应用机器人」,还是工作台应用、或群里发消息的机器人(→ `dingtalk-chat`);确认是开放平台场景后才走 dev 流程(接 C8)。
- **通过判据**:不直接假设走 dev,先澄清;能正确指向其它技能出口。
- **易错点**:上来就 `robot submit`,没确认是不是开放平台场景。
---
## 备注
- 10 条合起来覆盖全部 34 个子命令 + 8 类横切行为(见覆盖矩阵)。
- 评测可分两层:**静态**——无环境,只看 agent 选的命令/flag/判断是否符合「期望/通过判据」;**真机**——有联调环境时核对真实出参。
- 真机注意:`dev connect` 正式连接是长驻进程;`version publish`/`app delete` 等写操作请用占位应用或停在 dry-run,避免动真实数据。
+321
View File
@@ -0,0 +1,321 @@
# dws dev 一键安装与 Agent 接入指南
面向希望用 Codex、Claude、Cursor 等开发 Agent 管理钉钉开放平台应用的开发者。
这份指南参考 Notion Developer Platform 的引导方式:先给出一条可复制的安装命令,再用最短路径完成验证、登录、Agent 调用和排障。
## 一键安装
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
### macOS / Linux
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
### Windows(PowerShell)
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
这个脚本会:
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
2. 安装 `dws` 到默认目录 `~/.local/bin`。
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
支持这些环境变量(全部可选):
| 变量 | 说明 |
|---|---|
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
### 国内加速
`dws dev` 已在正式版里,国内用户直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
```bash
DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
```
## 安装后验证
先确认 `dws` 可执行:
```bash
dws version
```
确认 `dws dev app` 命令存在:
```bash
dws dev app --help --format json
```
如果能看到 `list`、`get`、`create`、`update`、`permission`、`member`、`robot`、`security`、`version`、`webapp`、`event`、`credentials` 等子命令,说明已安装成功。
确认登录状态:
```bash
dws auth status
```
如果尚未登录:
```bash
dws auth login
```
登录完成后读取应用列表:
```bash
dws dev app list --format json
```
## dws dev 是什么
`dws dev` 是钉钉开放平台开发者命令组,三块能力:
- `dws dev app` — 开放平台企业内部应用的全生命周期管理(创建、配置、权限、成员、安全、机器人、版本发布、事件订阅)。
- `dws dev connect` — 把现成机器人接到当前本地 agent(起 Stream 连接做本地转发,不建号、不产生审批工单)。
- `dws dev doc` — 开放平台开发文档搜索。
安装后,开发者和 Agent 可以用统一命令管理企业内部应用,而不需要反复进入开发者后台页面。它让 Agent 可以完成这些工作:
- 查询、创建、更新、启用、停用、删除开放平台应用。
- 查询应用凭证,读取 `clientId` / `appKey`,敏感凭证走专用命令。
- 配置网页应用首页和管理后台地址。
- 查询、申请、移除权限点。
- 管理应用成员。
- 配置安全项,包括 IP 白名单、登录重定向 URL、端内免登地址。
- 异步创建机器人、配置/启停现有机器人。
- 创建版本、发起发布、查询审批和发布状态。
## 给 Agent 使用
安装完成后,可以直接让 Agent 操作 `dws dev`。
示例:
```text
帮我查一下最近创建的开放平台应用。
```
```text
帮我给 unifiedAppId=<unifiedAppId> 的应用配置机器人,先 dry-run 给我确认。
```
```text
帮我查询这个应用缺哪些权限点,并申请 Contact.User.mobile。
```
```text
帮我发布这个应用版本,先预检是否需要审批。
```
Agent 写操作必须遵循:
1. 先查询定位应用。
2. 先 dry-run 预览。
3. 明确展示将要修改的应用、字段和值。
4. 用户确认后加 `--yes` 执行。
5. 执行后回读验证。
## 第一个写操作
推荐用机器人配置作为 smoke test。建号是异步的,分两步。
提交建号任务(记下返回的 `taskId`):
```bash
dws dev app robot submit \
--name "告警助手" \
--robot-name "告警机器人" \
--desc "处理告警通知和事件回调" \
--dry-run \
--format json
```
确认预览无误后去掉 `--dry-run`、加 `--yes` 执行,再用返回的 `taskId` 查结果,直到 `status` 变成 `SUCCESS`:
```bash
dws dev app robot result --task-id <taskId> --format json
```
对**已有机器人**的应用,改配置/启停用 `robot config` / `robot enable` / `robot disable`:
```bash
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "新机器人名称" --dry-run --format json
```
## 常用命令
### 应用管理
```bash
dws dev app list --format json
dws dev app get --unified-app-id <unifiedAppId> --format json
dws dev app create --name "考勤应用" --dry-run --format json
dws dev app update --unified-app-id <unifiedAppId> --name "新应用名" --dry-run --format json
dws dev app enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app disable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app delete --unified-app-id <unifiedAppId> --confirm-name "<应用名>" --format json
```
> 删除不可逆,需要用 `--confirm-name` 传入应用名做二次确认。
### 凭证查询
```bash
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
```
凭证输出可能包含敏感字段,不要把完整结果写入文档、日志或长期记忆。
### 权限点管理
```bash
dws dev app permission list --unified-app-id <unifiedAppId> --format json
dws dev app permission add --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
dws dev app permission remove --unified-app-id <unifiedAppId> --scope-values Contact.User.mobile --dry-run --format json
```
权限申请和移除只使用 `scopeValue`,不要传 API 名或权限分组名。
### 机器人能力
```bash
dws dev app robot get --unified-app-id <unifiedAppId> --format json
dws dev app robot submit --name "<智能体名>" --robot-name "<机器人名>" --desc "<描述>" --dry-run --format json
dws dev app robot result --task-id <taskId> --format json
dws dev app robot config --unified-app-id <unifiedAppId> --name "机器人名称" --dry-run --format json
dws dev app robot enable --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app robot disable --unified-app-id <unifiedAppId> --dry-run --format json
```
### 成员与安全
```bash
dws dev app member list --unified-app-id <unifiedAppId> --format json
dws dev app member add --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app member remove --unified-app-id <unifiedAppId> --user-ids <userId> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --redirect-urls <url> --dry-run --format json
dws dev app security config --unified-app-id <unifiedAppId> --ip-whitelist <ip> --dry-run --format json
```
### 网页应用与事件
```bash
dws dev app webapp get --unified-app-id <unifiedAppId> --format json
dws dev app webapp config --unified-app-id <unifiedAppId> --homepage-url <url> --dry-run --format json
dws dev app event list --unified-app-id <unifiedAppId> --format json
dws dev app event subscribe --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app event unsubscribe --unified-app-id <unifiedAppId> --dry-run --format json
```
### 版本发布
```bash
dws dev app version list --unified-app-id <unifiedAppId> --format json
dws dev app version create --unified-app-id <unifiedAppId> --dry-run --format json
dws dev app version check-approval --unified-app-id <unifiedAppId> --version-id <versionId> --format json
dws dev app version publish --unified-app-id <unifiedAppId> --version-id <versionId> --dry-run --format json
dws dev app version status --unified-app-id <unifiedAppId> --version-id <versionId> --format json
```
> 发布前先用 `version check-approval` 预检是否需要审批。含高敏权限的版本,`publish` 需加 `--confirmed-sensitive`。
## 安全边界
`dws dev` 的目标不是绕过开发者后台权限,而是让 CLI、MCP 和 Web 后台保持一致。
默认安全策略:
- 写操作先 dry-run。
- 删除、停用、发布必须由用户确认(删除还需 `--confirm-name` 二次确认)。
- Agent 不接收用户手动传入的 access token、cookie、`clientSecret`、`appSecret`。
- 应用定位优先使用 `unifiedAppId`、`agentId`、`appKey`。
- 对权限点申请、成员变更、安全配置、版本发布记录操作结果,便于审计和回滚。
## 排障
### `dws dev app` 不存在
先确认装上的是带 `dws dev` 的版本:
```bash
dws version
dws dev app --help --format json
```
如果命令缺失,重新执行本文的一键安装命令(或标准 `install.sh`)升级到最新正式版。
### `dws dev app list` 失败
优先检查登录态:
```bash
dws auth status
dws auth login
```
然后确认当前账号能访问目标企业,并且当前用户在目标企业内。
### 提示"当前用户没有开发者身份"
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试。
### 页面能操作,但 CLI 或 MCP 提示无权限
通常说明 CLI/MCP 后端鉴权和 Web 后台权限没有对齐。先确认当前用户是否满足以下任一条件:
- 应用 owner。
- 应用管理员。
- 应用开发者。
- 企业管理员或具备开放平台应用管理权限的角色。
### 机器人配置失败
先查当前机器人状态:
```bash
dws dev app robot get --unified-app-id <unifiedAppId> --format json
```
如果机器人不存在,用 `robot submit` 异步创建;如果已存在,用 `robot config` 修改,或用 `robot enable` 重新启用。
## 页面文案建议
用于产品页顶部:
```text
Install dws dev in one command.
Let your coding agents manage DingTalk Open Platform apps from the terminal:
create apps, configure robots, apply permissions, manage security settings,
and publish versions with dry-run safety built in.
```
中文版本:
```text
一行命令接入 dws dev。
让 Codex、Claude、Cursor 等开发 Agent 直接管理钉钉开放平台应用:
创建应用、配置机器人、申请权限、管理安全配置、发布版本。
所有写操作先预览,再确认执行。
```
## 参考
- Notion Developer Platform: https://www.notion.com/product/dev
- Notion CLI Help: https://www.notion.com/help/use-notion-from-your-terminal-with-notion-cli
- Notion Developer Platform Blog: https://www.notion.com/blog/introducing-developer-platform
+115
View File
@@ -0,0 +1,115 @@
# Event consume — AI subprocess contract
Aligns `dws event consume` with the "AI subprocess contract" that
`lark-cli event consume` exposes, so any orchestrator (Claude Code's
Monitor, a bash bridge, systemd, an agent plugin) can drive it with zero
ambiguity: know when it is ready, stop it cleanly, and machine-read why it
exited.
Scope of this branch: the four **contract** items below. Reconnect
resilience (keeping the stream alive across a transient upstream drop) is
tracked separately and intentionally out of scope here.
## Baseline (already present, no work)
- `--max-events N` — stop after N events (exit 0).
- `--duration D` — wall-clock budget (exit 0). Kept as `--duration`, NOT
aliased to `--timeout`: the global `--timeout` is the HTTP request
timeout (int seconds) and would collide (different type and meaning).
Docs note the lark-cli name difference.
- Bus idle-shutdown fires only with **zero** consumers, so a connected
consumer is never idle-killed.
- SIGINT/SIGTERM already cancel the run context and return cleanly.
## Improvements
### 1. Ready marker (standardized)
On connect, emit a fixed stderr line **before** any stdout event:
```
[event] ready event_key=<key> bus_pid=<pid>
```
Parents block on stderr until this line, then read stdout. Suppressed
under `--quiet`. Replaces the ad-hoc `connected bus pid=...` line (which
omits `event_key`).
**Verification**
- T1a: stderr contains a line matching `^\[event\] ready event_key=<key>`.
- T1b: that line appears before the first stdout event (ordering).
- T1c: with `--quiet`, the line is absent.
### 2. stdin EOF = graceful exit
`consume` watches stdin; closing stdin is a shutdown signal (wired for AI
subprocess callers). To stay resident, feed a never-EOF stdin
(`< <(tail -f /dev/null)`) or run bounded (`--max-events` / `--duration`).
**Verification**
- T2a: `printf '' | dws event consume <key>` exits ≤2s, code 0, final
line `reason: signal` (stdin-eof classified as signal).
- T2b: `dws event consume <key> < <(tail -f /dev/null)` still alive after
5s, connection intact.
- T2c (unit): a controllable stdin reader hitting EOF makes Run return nil
via the cleanup path.
### 3. Exit reason contract + exit codes
On exit, final stderr line:
```
[event] exited — received N event(s) in Xs (reason: <limit|timeout|signal|bus_shutdown>)
```
Exit codes: controlled exit (limit/timeout/signal/stdin-eof) = 0; startup
or runtime failure (permissions, network, params) = non-zero, with no
`exited` line and an `Error:` line instead.
**Verification**
- T3a: `--max-events 1` + 1 event → exit 0, reason=`limit`, N=1.
- T3b: `--duration 2s`, no events → exit 0, reason=`timeout`.
- T3c: SIGTERM mid-run → exit 0, reason=`signal`.
- T3d: bad params / permission failure → exit≠0, no `exited` line, has `Error:`.
- Unit tests assert (reason string, exit code) for each path.
### 4. Cleanup on exit (no `kill -9`)
Ownership-based, matching lark-cli:
- If this run **created** the subscription (no `--subscribe-id`), a clean
exit (SIGTERM / SIGINT / stdin-EOF / limit / timeout) **unsubscribes**
it server-side and sends Bye.
- If `--subscribe-id` was passed (reusing an existing subscription), the
subscription is **left intact** — the caller owns its lifecycle.
- `--ephemeral` remains as an explicit "always unsubscribe" override.
- Help/docs warn: avoid `kill -9` (skips the unsubscribe → leaked
server-side subscription: "subscription already exists" on restart,
duplicate delivery). Prefer SIGTERM or closing stdin.
**Verification**
- T4a: start consume (self-created subscription), record subscribe_id;
SIGTERM; afterwards `dws event status` no longer lists that subscribe_id
and the server-side subscription is gone.
- T4b: start consume with `--subscribe-id <existing>`; SIGTERM; the
subscription is still present (reuse case preserved).
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
## Out of scope (next branch)
**Reconnect resilience** — today `personal source` retries only
`retryable` errors (1–30s backoff); a non-retryable error tears the bus
down and takes consume with it (the likely cause of the observed silent
drop). Making more drops retryable, keeping the bus alive across a
reconnect, and emitting `reason: source_lost` only after exhausting the
budget — tracked on its own branch, since it needs error-classification
judgement and real flaky-network testing, and would otherwise couple clean
contract work with resilience work.
## Test surface
- Unit: extend `internal/event/consume/*_test.go` with fake bus conn /
stdin / stderr sink for T1c, T2c, T3 (all paths), T4 ownership branch.
- Integration/e2e: `--foreground` + mock source (or a short real run) for
T1a/b, T2a/b, T3a–d, T4a/b/c — assert the stderr contract lines and exit
codes.
+41 -23
View File
@@ -5,11 +5,12 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_SERVERS_URL` | Point discovery at a custom server registry endpoint / 将服务发现指向自定义端点 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `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 / 退出码
@@ -21,7 +22,7 @@
| 3 | Validation | Invalid input, flags, or parameter schema mismatch / 输入参数校验失败 |
| 4 | PAT | PAT authorization interception; stderr carries raw machine-readable PAT JSON / PAT 授权拦截;stderr 返回原始机器可解析 JSON |
| 5 | Internal | Unexpected internal error / 未预期的内部错误 |
| 6 | Discovery | Server discovery, cache, or protocol negotiation failure / 服务发现、缓存或协议协商失败 |
| 6 | Discovery | Static endpoint resolution or protocol negotiation failure / 静态端点解析或协议协商失败 |
With `-f json`, error responses include structured payloads: `category`, `reason`, `hint`, `actions`.
@@ -33,7 +34,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
dws schema -f pretty ding.send_ding_message # Pretty (ANSI-colored, schema-aware / 彩色分区,专为 schema 设计)
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
```
## Dry Run / 试运行
@@ -50,42 +51,59 @@ dws contact user search --query "Alice" -o result.json
## Schema Introspection / Schema 查询
`dws schema` 查询已发现的 MCP 产品和工具元数据。不带参数列出所有产品,带路径输出单个工具的完整 schema。
`--help` 展示当前二进制的 Cobra 命令和可接受 flag,`dws schema` 查询同版本内嵌的 Agent 命令契约。Schema 查询不访问 MCP endpoint、不执行 `tools/list`,也不搜索钉钉文档或任何业务数据。
Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 来自 reviewed `CommandRegistry`,并在发布时逐项绑定当前 Cobra tree。编辑 `internal/cli/schema_command_registry.json` 时必须遵守同目录的 `schema_command_registry.schema.json`;普通生成流程只校验该 reviewed input,不会覆盖它。Native annotation 只做实现一致性校验;Catalog 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
### 路径写法
```bash
dws schema # 列出所有产品 + 工具名
dws schema ding.send_ding_message # canonical: product.rpc_name
dws schema ding.message.send # CLI 点路径: product.group.cli_name
dws schema "ding message send" # CLI 空格路径(同上)
dws schema --cli-path "ding message send" # 显式 flag(脚本友好,免转义)
dws schema -f pretty ding.send_ding_message # ANSI 着色分区展示(人肉查看最舒服)
dws schema # 当前公开产品面的紧凑概览
dws schema calendar # 展开一个产品
dws schema "calendar event" # 展开一个命令分组
dws schema "calendar event create" # 按 CLI 空格路径查询工具
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
dws schema --cli-path "calendar event create" # 显式 CLI path
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
```
Canonical 路径先匹配;落空后走 CLI 路径(product → group.. → cli_name)。
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
### Schema、Help 与业务数据的边界
| 问题 | 事实源 |
|------|--------|
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
### 单工具输出字段
| 字段 | 说明 |
|------|------|
| `name` / `cli_name` / `canonical_path` | MCP RPC 名 / CLI 叶子名 / `product.rpc_name` |
| `group` | CLI 父级 group 路径(dot-separated) |
| `title` / `description` | 工具名/说明(overlay 优先) |
| `parameters` / `required` | MCP 输入 JSON Schema 的 properties / required |
| `output_schema` | MCP 输出 Schema(上游下发时才有) |
| `sensitive` | 敏感写操作,需 `--yes` 确认 |
| `annotations.destructive_hint` | 对齐 MCP 2025+ annotations,目前从 `sensitive` 映射 |
| `flag_overlay[param]` | CLI 层对 MCP 参数的改写:`alias` / `transform` / `transform_args` / `env_default` / `default` / `hidden` |
| `canonical_path` / `primary_cli_path` / `aliases` | 稳定工具 ID、主 CLI 路径和兼容路径 |
| `product_id` / `interface_ref` | CLI 产品与实际 MCP product/RPC binding |
| `title` / `description` / `agent_summary` | 人类说明、接口说明和 Agent 摘要 |
| `parameters.<flag>` | CLI flag 的类型、属性名、required、默认值、格式、枚举和条件必填 |
| `constraints` | one-of、互斥、联动等组合约束 |
| `effect` / `risk` / `confirmation` / `idempotency` | Agent 执行与安全策略 |
| `use_when` / `avoid_when` / `examples` | Agent 选择提示和示例 |
| `reviewed` / `agent_source_refs` | 语义审核状态与来源追踪 |
**调试 `--flag` 行为的第一站**是 `flag_overlay` —— 比如 `--users 0232...` 能不能直接用,看 `receiverUserIdList.transform == "csv_to_array"` 即可判断。
`parameters.<flag>.required` 是按来源 precedence 解析后的 Agent 参数契约;`cli_required=true` 才表示 Cobra 将该 flag 标记为硬必填。条件必填或别名选择通过 `required_when` 和 `constraints.require_one_of` 表达。`required` 不直接复制 MCP input schema,也不取代 Cobra 的实际执行校验。
### 筛选输出
```bash
dws schema ding.send_ding_message --jq '.tool.flag_overlay' # 只看 overlay
dws schema --jq '.products[] | {id, count: (.tools|length)}' # 各产品工具数
dws schema aitable.delete_base --jq '.tool.annotations' # 敏感操作提示
dws schema "calendar event create" --jq '.parameters' # 只看参数
dws schema "calendar event create" --jq '[.parameters | to_entries[] | select(.value.required)]' # 只看 Agent required 参数
```
## Shell Completion / 自动补全
+150
View File
@@ -0,0 +1,150 @@
# 钉钉 AI 群机器人快速上手
10 分钟搭一个自己的钉钉群答疑机器人:群里 @它 提问,它用你本地的 AI(Claude Code / Codex / Qoder 等)回答,支持发文字和报错截图。
只需四步:装工具 → 建机器人 → 接上 AI → 拉进群。
## 第一步:安装 dws
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
### macOS / Linux
打开终端,整段复制执行:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.sh | sh
```
> 国内用户:`dws dev` 已在正式版里,直接用标准安装脚本的 Gitee 镜像即可(二进制和 skill 都从 Gitee 拉,避免 GitHub 网络问题):
> ```bash
> DWS_GITEE_REPO=DingTalk-Real-AI/dingtalk-workspace-cli curl -fsSL https://gitee.com/DingTalk-Real-AI/dingtalk-workspace-cli/raw/main/scripts/install.sh | sh
> ```
装完按提示把 `~/.local/bin` 加进 `PATH`(脚本会在末尾提示),然后执行 `dws version` 确认。
### Windows
打开 PowerShell,整段复制执行:
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-devapp.ps1 | iex
```
然后**重新打开一个 PowerShell 窗口**,执行 `dws version` 确认。
> 能打印出版本号即安装成功(脚本默认装最新正式版)。脚本走 GitHub API 取最新 release,无需手动填版本号;想钉某个版本可设环境变量 `DEVAPP_VERSION`。
### 登录钉钉
```bash
dws auth login
```
按提示扫码登录即可。
## 第二步:创建机器人
建号是异步的,两步(名字、描述可以改成你自己的):
```bash
# 1) 提交建号任务,记下返回的 taskId
dws dev app robot submit --name 我的智能体 --robot-name 小助手 --desc "群内答疑" --yes --format json
# 2) 用上一步的 taskId 查结果,直到 status 变成 SUCCESS(还是 WAITING 就过几秒再查一次)
dws dev app robot result --task-id 上一步返回的taskId --format json
```
`status` 变成 `SUCCESS` 后,返回结果里的 `unifiedAppId` **记下来**,下一步要用。(`clientId` / `clientSecret` 也会返回,但下一步默认走 `unifiedAppId`,密钥由 dws 后台从 `credentials get` 自动拉取,你不需要手工复制密钥。)
## 第三步:把机器人接上你本地的 AI
```bash
dws dev connect --channel auto --unified-app-id 上一步的unifiedAppId
```
- 把 `上一步的unifiedAppId` 换成第二步返回的 `unifiedAppId` 实际值
- 只用 `--unified-app-id`:`clientSecret` 由 `dws dev app credentials get` 后台取回,**不会出现在你的命令行**,不会被 `ps` 看到、不会留在 shell 历史里
- `--channel auto` 自动识别你电脑上装的 AI 工具(Claude Code / Codex / Qoder / Gemini 等)
- 这个命令是前台运行的:窗口开着机器人在线,关掉窗口机器人下线
> 安全提示:老写法 `--robot-client-id <id> --robot-client-secret <secret>` 仍然能用,但 `clientSecret` 会以明文出现在命令行,任何本机用户 `ps -ef` 都能拉到;dws 会在 stderr 打一条 WARNING 提醒。除了没有 unifiedAppId 的老应用兜底之外,都建议改用 `--unified-app-id`。
## 第四步:拉进群聊
在钉钉里打开目标群:
**群设置 → 机器人 → 添加机器人 → 在企业机器人里搜"小助手"(你起的名字)→ 添加**
完成。现在在群里 @小助手 提问试试,发文字、发报错截图都能答。
## 进阶配置(可选)
按需加在第三步的命令后面:
| 参数 | 作用 |
|------|------|
| `--agent-workdir ./项目目录` | 让机器人在你的项目目录里跑,能读到和终端一样的本地文件(详见下方「机器人答得不如终端准?」) |
| `--knowledge-dir ./docs` | 挂本地知识目录(.md/.txt),回答自动带上你的资料 |
| `--agent-cmd "<命令>"` | 接入内置列表之外的 AI 工具(自研的、或还没内置支持的),详见下方「想用没在列表里的 AI 工具?」 |
| `--allowed-users 工号1,工号2` | 用户白名单,名单外的人无法触发机器人 |
| `--allowed-groups 群ID` | 群白名单 |
| `--user-rate-limit 0` | 关闭限流(默认每人每分钟 20 条) |
### 想用没在列表里的 AI 工具?(自研 / 未内置支持)
`--channel auto` 只认内置的几款工具(Claude Code / Codex / Qoder / Gemini 等)。如果你用的是自研的、或还没内置支持的 AI(比如网易有道龙虾 LobsterAI),用 `--agent-cmd` 把它接进来——只要它能在命令行「一次性」跑(给一段问题、把答案打到标准输出),就能接:
```bash
dws dev connect \
--agent-cmd "你的AI命令 一次性问答参数" \
--unified-app-id 你的unifiedAppId
```
机器人收到群消息后,会执行 `你的AI命令 一次性问答参数 "用户的问题"`(问题作为最后一个参数追加),把它打印出来的内容当作回复发回群里。
举例:假设龙虾的命令行叫 `lobster`、一次性问答用 `-p` 参数,就写 `--agent-cmd "lobster -p"`。命令里有空格就整体用引号括起来。
## 常见问题
**执行命令报 `zsh: parse error near '\n'`?**
命令里残留了 `<...>` 尖括号占位符(旧版文档的写法),shell 会把尖括号当成重定向符。把占位符整体替换成实际值、不要保留尖括号,再执行。
**群里 @机器人 没反应?**
确认第三步的 `dev connect` 窗口还开着——关掉窗口机器人就下线了。
**第二步提示"当前用户没有开发者身份"?**
创建应用需要开放平台开发者权限。请企业管理员在钉钉开放平台(open-dev.dingtalk.com)的「权限管理」中把你的账号添加为开发者,然后重试第二步。
**提示找不到 dws 命令?**
macOS 重开一个终端窗口;Windows 重开一个 PowerShell 窗口(安装时改了 PATH,需要新窗口才生效)。
**提示本地没有装 AI 工具?**
机器人背后需要一个本地 AI CLI。推荐先装 [Claude Code](https://claude.com/claude-code) 或 Codex,装好后重新执行第三步。
**机器人回复"调用失败"?**
通常是本地 AI 工具未登录或额度用尽,单独运行一次该 AI 工具确认其本身可用。
**机器人答得不如终端准?(同样的问题,终端对、机器人不对)**
这通常不是模型问题,而是"机器人看到的上下文比终端少":
- **工作目录不同**:默认机器人在一个空白临时目录里跑(为了启动快、回复中立),它看不到你终端所在项目里的文件。要让它和终端读到同样的资料,在第三步加 `--agent-workdir ./你的项目目录`(指到你平时在终端里跑 AI 的那个目录)。
- **知识没挂上**:如果靠的是本地文档/知识库,加 `--knowledge-dir ./docs`(或 `--knowledge-source wiki:<spaceId>`)把资料显式挂给机器人,别指望它自己去翻。
- **模型不同**:机器人默认走一个偏快的小模型;如果你终端用的是更强的模型,给机器人也指定同一个:`--agent-model <模型名>`。
- **回答"水位"上下浮动**:先确认没关 `--agent-memory`(默认开)。Codex 走 app-server thread 续聊;Qoder/Claude Code/CodeBuddy/WorkBuddy 走可恢复会话,其中 Qoder 的映射只保存在当前 DWS 进程内,重启后会重新开始;Gemini 仍是一次性调用。
一句话:让机器人和终端"看到一样的东西、用一样的模型",差距基本就抹平了。
## 会话指令:`/new` 和 `/clear`
机器人默认记住同一个会话的上下文(多轮对话)。想重置上下文,直接在聊天里发这两个斜杠指令——整条消息就是指令时才生效(普通问题不受影响),不消耗一次 AI 调用,秒回提示:
| 指令 | 作用 |
|------|------|
| `/new`(或 `/start`、`/reset`) | **开启新会话**:之前的上下文不再带入,旧会话保留(agent 支持的话仍可回溯) |
| `/clear` | **清空当前会话**:彻底从头开始 |
两者按各渠道**真实能力**对齐:`/clear` 在 opencode 渠道会真正删除当前会话(调 opencode 的 `DELETE /session/:id`);Codex / Qoder / Claude 系等驱动接口没有删除原语的渠道,`/clear` 退化为与 `/new` 相同的重置。
**第三步执行完,在蚂蚁钉/开放平台搜不到审批工单?**
这是正常的,不是出错。第三步 `dev connect`(把机器人接到本地 AI)只是用现成机器人的凭证起一条连接、本地转发,**不产生任何审批工单**。会产生审批工单的是第二步「建机器人」(`dev app robot submit`),由平台/管理员审批。所以第三步之后搜不到工单是预期内的。
+311
View File
@@ -0,0 +1,311 @@
# DWS Agent Schema 统一方案
## 1. 核心定义
DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描述真实 Cobra 命令,并补充 Agent 选择、参数映射、组合约束、安全确认和接口事实。
设计遵循三条硬规则:
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog.json` 和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
Schema 不调用 MCP `tools/list`,不访问网络,也不读取用户本地 discovery cache。
## 2. 单向数据流
```text
schema_command_registry.json (reviewed CommandRegistry source)
+ reviewed manual command additions
|
v
EffectiveCommandRegistry
|
v
exact binder to live Cobra tree
+ native identity consistency assertions
|
v
BoundCommandRegistry
|
+----------------------+
|
skills/mono Markdown + internal/cli/schema_hints/*.json |
+ schema_mcp_metadata.json |
| |
v |
Agent-metadata normalization |
| |
v |
schema_agent_metadata/*.json |
(generated normalized input) |
| |
+-----------------------+
|
live Cobra flag facts / typed parameter metadata
+ schema_hints/metadata/*.json (reviewed parameter overlay + safety)
+ schema_hints/selection/*.json (reviewed Agent selection prose)
+ schema_parameter_bindings.json (reviewed flag -> RPC property)
+ schema_mcp_metadata.json (pinned, sanitized interface facts)
+ normalized Agent metadata
|
v
source adapters + resolvers
|
v
one typed SchemaRegistry
(one ToolSpec per command)
+
typed SchemaIndex
+-----------+-----------+
| |
v v
build-time typed gates snapshot serializer
|
v
schema_catalog.json
(release output only)
|
v
go:embed -> typed loader
|
v
SchemaRegistry + SchemaIndex
|
+---------------------+------------------+
| | |
overview/product/group leaf --all
projections projection full projection
| | |
+---------------------+------------------+
|
v
runtime query + delivery gates
```
`--help` 是 Cobra 自身的人类可读投影,不从 Catalog 生成。Schema projections 和 `--help` 共享同一真实 Cobra 命令面,但承担不同职责。Binder 之后不得再从 annotation、manual hint 或生成 JSON 重新解析 command identity。
## 3. 与 Lark 的关系
DWS 与 Lark 保持**架构同构**,而不是强行复制字段:
| Lark 分层 | DWS 对应层 |
|---|---|
| typed command/metadata registry | `EffectiveCommandRegistry`、`BoundCommandRegistry` 与最终 `SchemaRegistry` |
| navigation catalog/index | 从同一 `ToolSpec` 派生的 `SchemaIndex` |
| schema renderer/envelope | overview、product/group、leaf、`--all` projections |
共同点是:强类型 registry 持有已审核、已绑定、已解析的事实,index 只负责确定性导航,renderer 只投影,不重新读取来源或做 precedence。DWS 的 base Registry 与 reviewed manual command additions 在绑定前合并为唯一的 `EffectiveCommandRegistry`,因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。
DWS 内部 resolved model 为:
```text
SchemaRegistry
-> []ProductSpec
-> []ToolSpec
-> ToolIdentitySpec
-> []ParameterSpec
-> RuntimeSchemaConstraints + []RuntimeSchemaPositional
-> SafetySpec
-> InterfaceSpec
-> SelectionSpec
-> map[field]FieldProvenance
```
字段合并和 precedence 在进入该模型前完成。`map[string]any`/flat JSON 只允许存在于 renderer 和 snapshot/wire boundary,不能作为内部 resolver、navigation 或 gate 的第二套数据模型。
DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和选择字段也保持现有键名。架构对齐不等于未版本化地切换到 Lark `inputSchema/outputSchema/_meta` envelope;若未来提供该格式,应作为明确版本的新投影,并保留现有兼容输出。
## 4. 来源职责
| 来源 | 负责内容 | 明确不负责 |
|---|---|---|
| `schema_command_registry.json` | reviewed `CommandRegistry`:稳定 canonical identity、primary CLI path、alias、exposure 和导航 | 创建 Cobra 命令/flag、参数、安全、endpoint/token |
| reviewed manual command additions | 将一个精确存在的 runnable Cobra leaf 合并进 `EffectiveCommandRegistry`;必须 reviewed 且带 reason | 运行时 fallback、覆盖冲突 identity、创建命令 |
| Go/Cobra | 路径是否真实可执行、Cobra 接受的 flag、CLI 类型/默认值、执行校验、help 文本 | 稳定 canonical identity、Agent 场景选择、虚构 RPC |
| native Schema identity annotations | implementation-side consistency evidence;存在时必须与 `EffectiveCommandRegistry` 精确一致 | 提供、补全、推断或覆盖 identity |
| `schema_hints/metadata/*.json` parameter overlays | 精确覆盖现有 flag 的描述、映射、类型和 required 语义;并承载 safety / `runtime_gate` / interface | 创建命令/flag、绕过 completeness、虚构 RPC |
| typed parameter metadata / constraints | `required_when`、one-of、互斥、联动、格式、枚举、位置参数 | 命令 identity |
| `schema_parameter_bindings.json` | 稳定 CLI flag 到 RPC property 的映射 | 命令发现、risk 推断 |
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实 | CLI identity、运行时路由、risk 推断 |
| `schema_hints/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
| `schema_catalog.json` 及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
`schema_command_registry.json` 承载 reviewed `CommandRegistry`。Manual command addition 先以确定性规则合并进 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
## 5. 统一解析与 precedence
### 5.1 Identity
- Reviewed base `CommandRegistry` 是 stable canonical identity、primary path、alias 和 navigation 的唯一基础事实源。
- Reviewed manual command addition 只能引用精确存在的 runnable Cobra leaf;它在绑定前合并进 `EffectiveCommandRegistry`。若与 base Registry 的 identity/path/alias 冲突,生成失败,不能按 precedence 静默覆盖。
- Binder 必须把 effective entry 的 primary path 和每个 alias 精确解析到同一个真实 executable leaf;stale path、phantom path、重复 identity 或 alias collision 全部失败。
- Native identity annotation 是可选的一致性证据:存在时必须与 effective entry 精确一致;缺失不触发补写、推断或 fallback。
- Public runnable Cobra leaf 未进入 effective registry 时,必须存在 exact、reviewed、带 reason 的 exclusion;不得用 prefix/wildcard 排除。
- Identity 不做名称推断,不从 Catalog/generated metadata fallback,也没有多来源 winner。
删除 native materialization 前已做写入审计:旧
`ApplyNativeRuntimeSchemaContracts` 的唯一写操作是对已存在命令调用
`AttachRuntimeSchema`,只写 command identity 的 product/tool/source annotation;
它不写 flag property/type/required、constraints、positionals、title/description
或 interface mapping。这些字段原本已分别由 parameter binding/metadata、
constraint、Cobra help 和 interface resolver 提供,因此删除该过渡层没有数据迁移缺口。
CI 同时禁止重新加入 generated native contracts 或 materialization 入口。
#### CommandRegistry 输入审计
`schema_command_registry.json` 是 reviewed source,不是生成快照。它必须保留
`$schema: ./schema_command_registry.schema.json`。该 JSON Schema 对 root、product
和 CommandSpec 全部使用 `additionalProperties: false`,并约束:
- canonical identity、`source_product_id` 和精确 CLI path 的格式;
- `aliases` 唯一且不能复用 primary path;
- `visibility` 只允许 `public | compat | internal`,省略时明确归一化为
`public`;
- primary path、alias、canonical 和 product 之间无法由 JSON Schema 表达的
交叉约束,继续由 Go strict loader 和 Cobra binder fail-closed 校验。
Registry semantic hash 覆盖 canonical、primary CLI path、alias 集合、
`source_product_id` 和 normalized visibility。格式、顺序以及省略的等价默认值
不改变 hash;上述任一稳定契约字段变化都必须改变 hash。测试逐字段验证这一点,
不使用当前命令数量作为常量。
普通 `go generate ./internal/cli` 只把 Registry 作为 validation-only 输入并生成
Agent metadata/Catalog 等单向下游资产,不生成或覆盖 Registry。drift policy 在生成
前后对 reviewed Registry 做 byte-for-byte guard;独立的
`check-schema-command-registry.sh` 在 interface/provenance/Catalog policy 之前检查
JSON 输入契约、禁用旧 native materialization 符号,并从 Registry 动态计算审计
数量,不能硬编码某次快照的 tool count。
### 5.2 Parameter
每个字段按明确的来源 precedence 选择一次,并把 winner、候选值和来源写入 provenance。precedence **与值无关**:不能因为 `required=true` 看起来更严格就让它越级获胜。更高优先级的 reviewed manual override 可以把 `required`、映射、interface type 或描述调高,也可以调低。
实现中的参数字段顺序固定为:
```text
reviewed manual > versioned binding > command constraint > typed metadata
> native/Cobra contract > ToolSchemaHint > MCP metadata
> inference/default
```
命令 `title` / `description` 使用独立但同样确定的文本顺序:
```text
reviewed ToolSchemaHint > command-specific Cobra Help > MCP metadata > inference
```
因此多个 CLI leaf 复用同一个 RPC 时,通用 RPC 文案只能作为未选中的
provenance candidate 保留;参数级 RPC 文案可进入 `interface_description`,
但不得覆盖 leaf 自己的标题和执行语义。
Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/provenance 保留;它不应在 renderer 中再次静默改写已经解析的 Agent projection。
### 5.3 Safety、selection 与 interface
`effect`、`risk`、`confirmation`、`idempotency`、selection 和 interface disposition 同样按 source precedence 解析,而不是按值的“严格程度”合并。更高优先级的 reviewed explicit/manual source 可以升高或降低最终值;同 precedence 的不同值必须报冲突。
最终 interface disposition 还必须满足 conflict matrix:
- `mode` 与 `availability` 正交:`mode` 只允许 `mcp | local | composite`,`availability` 只允许 `available | unavailable`;`unavailable` 不是第四种 mode。
- `mcp + available`:只表示命令可由一个 pinned、参数可映射且语义等价的 `interface_ref` 完整表达;本地 wrapper 只是固定默认值或投影返回值时,也必须先证明参数和执行语义没有漂移。
- `local + available`:仅用于纯本地进程、静态数据或策略操作,不得携带 direct `interface_ref`;“远端 RPC 尚未进入 pinned metadata”不能归类为 local。
- `composite + available`:用于多 RPC、条件路由、本地投影,或 reviewed unpinned remote adapter;不得用单个 `interface_ref` 冒充完整实现,且必须提供 reviewed reason。未来需要表达多个 RPC 时使用单独的复合接口模型。
- 任意合法 mode + `unavailable`:不得携带 `interface_ref`,必须提供明确 reason,并且 Agent 不得把它当作可用接口。
## 6. Schema、Help 与业务数据边界
| 问题 | 事实源 |
|---|---|
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
Schema 和 Help 冲突是契约漂移,不能静默猜测:
- 执行参数以 Cobra 实际接受的 flags 为准;不要发送 Help 中不存在的 flag。
- 安全语义冲突时不要采用更宽松值。先按更保守的解释确认;如果无法确定安全执行方式,停止并报告漂移。
- Schema/Help 只完成命令发现和契约读取。需要业务结果时,必须继续执行真实 read/search/list 命令。
上述运行时漂移策略不改变构建期的 value-neutral precedence;前者是在契约已经互相矛盾时保护用户,后者是在确定性生成同一契约。
## 7. 查询投影
```bash
dws schema # 产品紧凑概览
dws schema calendar # 产品摘要
dws schema "calendar event" # 分组摘要
dws schema "calendar event create" # 完整 leaf
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
dws schema --all # 所有工具的完整 leaf 导出
```
`schema list` 是根概览的兼容入口。
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
## 8. 生成与发布
当 Cobra、flag、identity、binding、manual hint、Agent hint 或 Skill 发生变化时:
1. 审核真实 Cobra 变化,确认命令和 flag 已实际存在。新增或修改稳定 command identity、primary CLI path 或 alias 时,精确编辑 reviewed `CommandRegistry`(当前持久化文件为 `schema_command_registry.json`)。参数、Skill 或 metadata 单独变化时不要机械改写 Registry,也不要从旧 Catalog 反向生成它。
2. 仅对明确例外使用 reviewed manual command addition;它必须精确引用现有 runnable leaf、带 reason,并在生成时归一化进 `EffectiveCommandRegistry`。Native identity annotation 若存在,应作为与 Registry 一致的实现断言维护,而不是用来 materialize identity。
3. 生成 Agent metadata:
```bash
make generate-schema-agent-metadata
```
4. 从统一 typed registry 生成最终 Catalog:
```bash
make generate-schema-catalog
```
也可以运行 `go generate ./internal/cli` 生成正常发布资产。生成文件包括:
- `internal/cli/schema_agent_metadata/index.json`
- `internal/cli/schema_agent_metadata/<product>.json`
- `internal/cli/schema_agent_metadata_audit.json`
- `internal/cli/schema_catalog.json`
只编辑来源;不要手工编辑 Agent metadata 或 Catalog 输出。
## 9. Completeness 与 final-delivery invariant
门禁必须验证最终交付对象,而不是某个中间层或数量:
- 每个 public runnable Cobra leaf 要么能通过最终 embedded `SchemaIndex` 查询,要么有 exact、reviewed、带 reason 的 exclusion。
- 每个最终 canonical path、primary CLI path 和 alias 都必须解析到同一个可执行 leaf;不得有 phantom path 或 collision。
- `EffectiveCommandRegistry`、`SchemaRegistry/SchemaIndex`、Agent metadata 和 Catalog canonical sets 必须精确一致,不能只比较 count。
- Leaf payload、`--all` 中对应 tool 和 Catalog full tool 必须是同一个 resolved `ToolSpec` 的内容级等价投影,并通过 production loader round-trip。
- overview/product/group summary 与 Catalog summary 必须等于同一个 `ToolSpec.ToSummaryPayload()`;alias 查询只允许 `cli_path` 和 `is_alias` 这两个视图字段变化。
- 每个最终字段及 parameter field 的 provenance winner value 必须与 delivered value 精确一致;不能只验证 provenance source、count 或字段是否存在。
- 每个 MCP `interface_ref` 必须在 pinned interface registry 精确存在;local/composite/unavailable 必须满足同一 conflict matrix。
- `--all` 的 tool set 必须与最终 index 一对一,且每个工具包含完整参数契约。
- 连续两次生成必须字节稳定,提交的生成物不得漂移。
推荐本地验证:
```bash
make generate-schema-agent-metadata
make generate-schema-catalog
./scripts/policy/check-generated-drift.sh
./scripts/policy/check-schema-catalog.sh
go test ./internal/cli ./internal/app ./internal/generator/... -count=1
```
## 10. 明确禁止
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
- 从旧 `schema_catalog.json` 或其他 generated JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
- 用 prefix/wildcard exclusion 隐藏未来命令。
- 让 manual hint、CommandRegistry 或 interface metadata 宣称一个不存在的命令、flag 或 RPC 可用。
- 把 `schema --all` 当作普通业务数据查询,或把其完整结果无条件注入 Agent 上下文。
+174
View File
@@ -0,0 +1,174 @@
# Shortcut 真实测试:后端 / MCP 问题整理
这份报告只汇总 `failure_category = backend-or-mcp-error` 的 case,已尽量排除权限、缺真实资源、当前账号无数据等噪音。
## 总览
- Backend/MCP case 总数:33
- 聚合问题数:8
- 复现口径:真实 dws CLI;无 mock;无 dry-run;命令输入和 trace_id 均来自真实测试结果。
## 建议优先看
1. [P1] Chat/IM 会话 ID 字段在 MCP/后端映射中疑似丢失(15 case)
2. [P1] Chat card 发送 receiverUid 疑似未从 receiver 透传(1 case)
3. [P1] Chat 入群审批 applicantUid/inviterUid 疑似未透传(1 case)
4. [P1] AI 表格 MCP 错误 envelope 语义不一致:success=true 但 error 非空/status=error(5 case)
5. [P1] AI 表格 Workflow 查询在真实 Base 下返回系统级错误(2 case)
6. [P1] AI 表格 roleId 参数疑似未被 MCP 正确读取(3 case)
7. [P2] AI 表格记录主文档查询在真实 record 下返回 no record/SYSTEM_ERROR(2 case)
8. [P2] AI 表格无效 Base/Table/Field/Record 被包装成 SYSTEM_ERROR(4 case)
## Chat/IM 会话 ID 字段在 MCP/后端映射中疑似丢失
- 优先级:P1
- 建议 owner:IM MCP / IM 后端字段映射
- 现象:CLI 已传 group/conversation-id/open-conversation-id(部分 case 使用真实 cid),后端仍报 openCid/openConversationId/cid required。
- 期望:MCP schema/网关应接受并透传 openConversationId/openCid/cid 中的兼容字段;如果资源无效,应返回“无效会话”,而不是 required。
- 涉及 case:15
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `read` | `chat +chat-members-get` | `tools/call` | `2127d89817840997754345760e07bd` | [UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +chat-members-get --id DWSREALREADNOSUCHID0000000000000 --users '冬翔' --yes --format json` |
| `read` | `chat +chat-messages` | `tools/call` | `2104a64c17840997767792656e085e` | [UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +chat-messages --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --limit 10 --direction older --yes --format json` |
| `read` | `chat +messages-list` | `tools/call` | `2127d89817840997797873841e0757` | [UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +messages-list --group cid3Jijzhe2aqs9ysOXjhi05g== --time '2026-07-15 10:00:00' --forward --limit 10 --yes --format json` |
| `write` | `chat +chat-mute-member` | `tools/call` | `2104a64c17840999166036583e08a3` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +chat-mute-member --group cidDWSREALTESTNOSUCHCONV --users __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --mute-time 1 --off --yes --format json` |
| `write` | `chat +chat-transfer-owner` | `tools/call` | `0b5deb3217840999222318863e087a` | [UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +chat-transfer-owner --group cidDWSREALTESTNOSUCHCONV --new-owner __DWS_SHORTCUT_REAL_TEST_NO_SUCH_USER__ --yes --format json` |
| `write` | `chat +conversation-clear-messages` | `tools/call` | `2127d89817840999254816721e079b` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-clear-messages --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
| `write` | `chat +conversation-clear-red-point` | `tools/call` | `2104a64c17840999265511295e085f` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-clear-red-point --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
| `write` | `chat +conversation-hide` | `tools/call` | `2127d89817840999276117140e079b` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-hide --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
| `write` | `chat +conversation-mark-unread` | `tools/call` | `0bb7c36217840999298744910e0758` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-mark-unread --conversation-id cidDWSREALTESTNOSUCHCONV --yes --format json` |
| `write` | `chat +conversation-mute` | `tools/call` | `2104a64c17840999309241865e085f` | [UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
| `write` | `chat +conversation-mute-at-all` | `tools/call` | `2127d89817840999320028068e07dd` | [UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute-at-all --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
| `write` | `chat +conversation-mute-red-envelope` | `tools/call` | `0bb7c36217840999330228283e07fe` | [UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-mute-red-envelope --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
| `write` | `chat +conversation-set-top` | `tools/call` | `2127d89817840999341302961e07fe` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +conversation-set-top --conversation-id cidDWSREALTESTNOSUCHCONV --off --yes --format json` |
| `write` | `chat +messages-set-pin` | `tools/call` | `0bb7c36217840999521117991e0758` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +messages-set-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
| `write` | `chat +messages-unset-pin` | `tools/call` | `2104a64c17840999544428103e08ee` | [UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +messages-unset-pin --open-conversation-id cidDWSREALTESTNOSUCHCONV --msg-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
## Chat card 发送 receiverUid 疑似未从 receiver 透传
- 优先级:P1
- 建议 owner:IM MCP / card 发送参数映射
- 现象:CLI 传入 receiver=103262,后端仍报 receiverUid 和 openConversationId 不能同时为空。
- 期望:receiver 应映射为 receiverUid,或 schema 明确要求 receiverUid;真实入参不应在 MCP 层丢失。
- 涉及 case:1
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `write` | `chat +messages-send-card` | `tools/call` | `2104a64c17840999509255753e081a` | [UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +messages-send-card --receiver 103262 --yes --format json` |
## Chat 入群审批 applicantUid/inviterUid 疑似未透传
- 优先级:P1
- 建议 owner:IM MCP / 入群审批参数映射
- 现象:CLI 传入 applicant=103262、inviter=519019,后端仍报 applicantUid required。
- 期望:applicant/inviter 应映射为 applicantUid/inviterUid;如果 recordId/group 无效,应返回对应资源错误而不是 applicantUid 缺失。
- 涉及 case:1
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `write` | `chat +chat-audit-join` | `tools/call` | `0bb7c36217840999154832733e0758` | [UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group) hint: Use --verbose for detailed error logs |
| | input | | | `/private/tmp/dws-real-test chat +chat-audit-join --group cidDWSREALTESTNOSUCHCONV --record-id 999999999999 --applicant 103262 --inviter 519019 --status AuditApprove --description 'DWS shortcut 真实测试描述,可删除' --yes --format json` |
## AI 表格 MCP 错误 envelope 语义不一致:success=true 但 error 非空/status=error
- 优先级:P1
- 建议 owner:AI 表格 MCP wrapper
- 现象:多条 AI 表格命令返回 MCP_TOOL_ERROR,内部 JSON 同时出现 success=true、status=error、error 非空。
- 期望:只要 error 非空或 status=error,success 应为 false,外层也应按业务错误返回稳定错误码/trace。
- 涉及 case:5
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `read` | `aitable +export-data` | `-` | `2104a64c17840997514714448e0817` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"taskId cannot be combined with scope, format, tableId or viewId","retryable":false,"type":"INPUT_ERROR"},"m… |
| | input | | | `/private/tmp/dws-real-test aitable +export-data --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --task-id DWSREALREADNOSUCHID0000000000000 --scope all --format excel --table-id hERWDMS --view-id qvGDAH2 --timeout-ms 1 --yes` |
| `write` | `aitable +chart-update` | `-` | `2106d98117840998553244877e08df` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"config is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
| | input | | | `/private/tmp/dws-real-test aitable +chart-update --base-id DWSREALTESTNOSUCHID0000000000000 --dashboard-id DWSREALTESTNOSUCHID0000000000000 --chart-id DWSREALTESTNOSUCHID0000000000000 --config '{}' --layout '{}' --yes --format json` |
| `write` | `aitable +record-update` | `-` | `0bab027317840998747383236e090b` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_RECORDS","message":"records must contain at least one writable record","retryable":false,"type":"INPUT_ERROR"},"meta":{},"stat… |
| | input | | | `/private/tmp/dws-real-test aitable +record-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[{"recordId":"recDWSREALTEST","cells":{}}]' --yes --format json` |
| `write` | `aitable +record-upsert` | `-` | `2106d98117840998759832182e087b` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMETER","message":"records is required and must not be empty","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"e… |
| | input | | | `/private/tmp/dws-real-test aitable +record-upsert --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --records '[]' --yes --format json` |
| `write` | `aitable +view-set-fill-color-rule` | `-` | `2106d98117840998924181709e08df` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"conditionalFormats is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success… |
| | input | | | `/private/tmp/dws-real-test aitable +view-set-fill-color-rule --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --view-id DWSREALTESTNOSUCHID0000000000000 --json '{}' --yes --format json` |
## AI 表格 Workflow 查询在真实 Base 下返回系统级错误
- 优先级:P1
- 建议 owner:AI 表格 Workflow MCP / 后端
- 现象:使用真实可访问 Base 查询 workflow list/get,返回 LIST_WORKFLOWS_ERROR/GET_WORKFLOW_ERROR。
- 期望:无 workflow 时应返回空列表或 WORKFLOW_NOT_FOUND;有后端异常时需提供稳定错误码和可排查 trace。
- 涉及 case:2
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `read` | `aitable +workflow-get` | `-` | `2104a64c17840997556363676e08ee` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"GET_WORKFLOW_ERROR","message":"调用远程服务业务异常","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
| | input | | | `/private/tmp/dws-real-test aitable +workflow-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --workflow-id DWSREALREADNOSUCHID0000000000000 --yes --format json` |
| `read` | `aitable +workflow-list` | `-` | `2127d89817840997572427879e075d` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"LIST_WORKFLOWS_ERROR","message":"biz error","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary… |
| | input | | | `/private/tmp/dws-real-test aitable +workflow-list --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --limit 10 --offset 1 --yes --format json` |
## AI 表格 roleId 参数疑似未被 MCP 正确读取
- 优先级:P1
- 建议 owner:AI 表格 MCP role 接口
- 现象:CLI 已传 --role-id,但 MCP 返回 roleId is required。
- 期望:role-id/roleId 字段应被正确映射;如果 role 不存在,返回 ROLE_NOT_FOUND,而不是 required。
- 涉及 case:3
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `read` | `aitable +role-get` | `-` | `2104a64c17840997542904838e0817` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
| | input | | | `/private/tmp/dws-real-test aitable +role-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --role-id x --yes --format json` |
| `write` | `aitable +role-delete` | `-` | `2106d98117840998782482701e089c` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
| | input | | | `/private/tmp/dws-real-test aitable +role-delete --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
| `write` | `aitable +role-update` | `-` | `2132f5ca17840998794483634e08d8` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"INVALID_PARAMS","message":"roleId is required","retryable":false,"type":"INPUT_ERROR"},"meta":{},"status":"error","success":true,"summ… |
| | input | | | `/private/tmp/dws-real-test aitable +role-update --base-id DWSREALTESTNOSUCHID0000000000000 --role-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --role-type x --flow-type x --sub-roles '[]' --yes --format json` |
## AI 表格记录主文档查询在真实 record 下返回 no record/SYSTEM_ERROR
- 优先级:P2
- 建议 owner:AI 表格 primary doc MCP / 后端
- 现象:record-query 已能查到真实 recordId,但 primary-doc 查询返回 no record、type=SYSTEM_ERROR。
- 期望:若该记录无主文档,应返回空/未创建;若 recordId 语义不匹配,应返回明确参数错误,不应是系统错误。
- 涉及 case:2
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `read` | `aitable +base-get-primary-doc-id` | `-` | `0b5deb3217840997466255627e08ee` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query… |
| | input | | | `/private/tmp/dws-real-test aitable +base-get-primary-doc-id --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json` |
| `read` | `aitable +record-primary-doc-get` | `-` | `2127d89817840997528393730e079c` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"-1","message":"no record","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary":"Failed to query… |
| | input | | | `/private/tmp/dws-real-test aitable +record-primary-doc-get --base-id gpG2NdyVXQyZ0OmoSbd1vbA6JMwvDqPk --table-id hERWDMS --record-id 1015oH3OXy --yes --format json` |
## AI 表格无效 Base/Table/Field/Record 被包装成 SYSTEM_ERROR
- 优先级:P2
- 建议 owner:AI 表格 MCP wrapper / 后端错误码
- 现象:安全负向 ID 下,部分写接口返回 getDentryDTO returns null、type=SYSTEM_ERROR、retryable=true。
- 期望:资源不存在应返回 INPUT_ERROR/NOT_FOUND 且 retryable=false,避免误导调用方重试。
- 涉及 case:4
| 套件 | shortcut | operation | trace_id | 证据 |
|---|---|---|---|---|
| `write` | `aitable +field-delete` | `-` | `0bab027317840998611338768e08c8` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
| | input | | | `/private/tmp/dws-real-test aitable +field-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
| `write` | `aitable +field-update` | `-` | `213ee25c17840998623207342e08e2` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
| | input | | | `/private/tmp/dws-real-test aitable +field-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --field-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --config '{}' --ai-config '{}' --yes --format json` |
| `write` | `aitable +record-delete` | `-` | `2132f5ca17840998724753149e0853` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
| | input | | | `/private/tmp/dws-real-test aitable +record-delete --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --record-ids DWSREALTESTNOSUCHID0000000000000 --yes --format json` |
| `write` | `aitable +table-update` | `-` | `213ee25c17840998877246229e087f` | [MCP_TOOL_ERROR] {"data":{},"error":{"code":"404","message":"getDentryDTO returns null","retryable":true,"type":"SYSTEM_ERROR"},"meta":{},"status":"error","success":true,"summary"… |
| | input | | | `/private/tmp/dws-real-test aitable +table-update --base-id DWSREALTESTNOSUCHID0000000000000 --table-id DWSREALTESTNOSUCHID0000000000000 --name 'DWS shortcut 真实测试 20260715-151724' --description 'DWS shortcut 真实测试描述,可删除' --record-name-key task --yes --format json` |
File diff suppressed because one or more lines are too long
+279
View File
@@ -0,0 +1,279 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Shortcut 真实测试失败逐项 review</title>
<style>
:root{--bg:#0f1420;--card:#151d2b;--line:#263246;--text:#dce7f7;--muted:#91a0b5;--blue:#8fd3ff;--green:#66d38a;--yellow:#e2b23c;--red:#f27272;--purple:#d3a7ff}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:13px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Microsoft YaHei",sans-serif}
header{padding:28px 32px 14px;border-bottom:1px solid var(--line);background:linear-gradient(180deg,#172033,#0f1420)}
h1{margin:0 0 8px;font-size:26px}
h2{margin:28px 0 10px;font-size:18px}
.sub,.note,.count{color:var(--muted)}
.wrap{padding:18px 32px 40px;max-width:1800px;margin:0 auto}
.note{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin:10px 0 18px}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin:18px 0}
.stat{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px}
.stat .n{font-size:24px;color:var(--blue);font-weight:700}
.stat .l{color:var(--muted);font-size:12px}
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:18px;margin:16px 0 22px}
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left}
th{background:#1b2536;color:var(--muted);font-size:12px;font-weight:600;position:sticky;top:0;z-index:1}
tr:last-child td{border-bottom:none}
code{font-family:"SF Mono",Menlo,Consolas,monospace;color:#c7cfdb;font-size:12px}
.review{table-layout:fixed}
.review th:nth-child(1),.review td:nth-child(1){width:44px}
.review th:nth-child(2),.review td:nth-child(2){width:165px}
.review th:nth-child(3),.review td:nth-child(3){width:70px}
.review th:nth-child(4),.review td:nth-child(4){width:120px}
.review th:nth-child(5),.review td:nth-child(5){width:130px}
.review th:nth-child(8),.review td:nth-child(8){width:130px}
.review th:nth-child(9),.review td:nth-child(9){width:180px}
.review td{word-break:break-word}
.num{color:var(--muted);text-align:right}
.risk{color:var(--green)}
.cat{color:var(--yellow)}
.owner{color:var(--purple)}
.evidence{color:#c7cfdb;font-size:12px}
a{color:var(--blue)}
</style>
</head>
<body>
<header>
<h1>Shortcut 真实测试失败逐项 review</h1>
<div class="sub">由 <code>scripts/gen_shortcut_error_review.py</code> 从真实测试结果生成;目标是把每个失败项落到“应该改哪里”。</div>
</header>
<main class="wrap">
<div class="note">
Read:204 条,成功 162,失败 41,超时 0。
Write:162 条,成功 48,失败 114,超时 0。
判定口径:如果 fake MCP 已看到字段但真实后端仍报 required,按后端/MCP schema 映射处理;如果真实后端报资源无效/不存在,按 fixture 处理;权限类不在 CLI 中绕过。
</div>
<div class="stats"><div class="stat"><div class="n">41</div><div class="l">Read 失败</div></div>
<div class="stat"><div class="n">114</div><div class="l">Write 失败</div></div>
<div class="stat"><div class="n">156</div><div class="l">逐项 review</div></div>
<div class="stat"><div class="n">72</div><div class="l">测试数据/fixture</div></div>
<div class="stat"><div class="n">22</div><div class="l">权限/应用配置</div></div>
<div class="stat"><div class="n">33</div><div class="l">后端/MCP schema</div></div></div>
<div class="summary-grid">
<section><h2>按错误类型</h2><table><thead><tr><th>类型</th><th>数量</th></tr></thead><tbody><tr><td>输入/业务校验</td><td>36</td></tr>
<tr><td>后端/MCP</td><td>33</td></tr>
<tr><td>缺 AI 表格 fixture</td><td>31</td></tr>
<tr><td>缺真实资源</td><td>30</td></tr>
<tr><td>鉴权/权限</td><td>22</td></tr>
<tr><td>缺妙记 fixture</td><td>3</td></tr>
<tr><td>敏感/高风险暂缓</td><td>1</td></tr></tbody></table></section>
<section><h2>按要改哪里</h2><table><thead><tr><th>要改哪里</th><th>数量</th></tr></thead><tbody><tr><td>测试数据</td><td>72</td></tr>
<tr><td>后端/MCP schema</td><td>33</td></tr>
<tr><td>权限/应用配置</td><td>22</td></tr>
<tr><td>测试输入/业务校验</td><td>13</td></tr>
<tr><td>后端业务/测试 fixture</td><td>11</td></tr>
<tr><td>测试输入</td><td>2</td></tr>
<tr><td>人工安全确认</td><td>1</td></tr>
<tr><td>测试输入/shortcut 枚举</td><td>1</td></tr>
<tr><td>测试输入/业务规则</td><td>1</td></tr></tbody></table></section>
</div>
<h2>缺真实资源复盘 <span class="count">· 可自造/可查资源处理结果</span></h2>
<table>
<thead><tr><th>命令/范围</th><th>处理状态</th><th>本次实际排查/造数结果</th><th>后续建议</th></tr></thead>
<tbody><tr><td><code>chat +group-members</code></td><td><span class="cat">已补齐</span></td><td>查到真实群名 `浅曦-kida,Dennis,秋画`,runner 已改为用群名而不是 openConversationId;真实回归成功。</td><td>无需后续动作。</td></tr>
<tr><td><code>chat +messages-mget</code></td><td><span class="cat">已补齐</span></td><td>复用真实单聊消息 `msgEuOor1PmFBNlx9M06N9z1Q==`;真实回归成功。</td><td>无需后续动作。</td></tr>
<tr><td><code>chat +messages-read-status</code></td><td><span class="cat">已补齐</span></td><td>复用真实单聊会话 `cidie1367hAfBxqipzE59k5sknHLrHmvYkw98NADhfnjPI=` 与同一 openMessageId;真实回归成功。</td><td>无需后续动作。</td></tr>
<tr><td><code>chat +messages-query-send-status</code></td><td><span class="cat">已补齐</span></td><td>复用真实发送返回的 openTaskId;真实回归成功。</td><td>无需后续动作。</td></tr>
<tr><td><code>ding +receiver-status</code></td><td><span class="cat">已补齐</span></td><td>先只读 `ding +list` 找到已有 openDingId,再查询 receiver status;没有新发 DING,真实回归成功。</td><td>无需后续动作。</td></tr>
<tr><td><code>sheet +list-sheets</code></td><td><span class="cat">已自造</span></td><td>创建临时在线表格 `DWS shortcut 真实测试表格 20260715`,nodeId=`mweZ92PV6O36dZbnsMZx70ylJxEKBD6p`;真实回归成功。</td><td>后续可保留为稳定 fixture,或测试结束后人工清理。</td></tr>
<tr><td><code>todo +todo-done</code></td><td><span class="cat">已自造并修复 CLI</span></td><td>runner 会先创建当前账号自己的临时待办,再执行 `todo +todo-done`;同时修复了 todo 列表 pageSize=50 返回空、响应多层 result unwrap 不稳的问题;真实回归成功。</td><td>无需后续动作;代码已有单测覆盖 nested result。</td></tr>
<tr><td><code>contact +by-mobile</code></td><td><span class="cat">已按用户授权补齐</span></td><td>使用用户指定手机号 `13161187007` 作为真实 fixture;runner 只在该命令上替换 mobile,不扩散到其它服务。</td><td>真实回归成功后该项将从失败列表移除;若后续要脱敏公开报告,可再加展示层脱敏。</td></tr>
<tr><td><code>attendance +get-class / +get-group / +get-group-filtered</code></td><td><span class="cat">不建议自造</span></td><td>`attendance +search-class` 与 `+search-group --type FIXED` 均返回空;创建班次/考勤组会改组织考勤配置,属于高影响业务数据。</td><td>需要考勤后端/业务同学提供可读测试班次与考勤组 ID。</td></tr>
<tr><td><code>chat +chat-get-by-id</code></td><td><span class="cat">暂未找到</span></td><td>该 shortcut 只接受数字 groupId;真实群列表只返回 openConversationId,没有数字群号字段。</td><td>需要 IM 后端提供可用数字 groupId,或评估是否新增 openConversationId 形态的 shortcut。</td></tr>
<tr><td><code>chat +messages-resource-url</code></td><td><span class="cat">暂未自造</span></td><td>需要真实含 mediaId 的图片/文件/视频消息;当前文本消息无法产生 resource-id。</td><td>可在测试群发一条图片/文件消息并提取 mediaId 后补 fixture;注意会产生群消息。</td></tr>
<tr><td><code>chat +thread-replies</code></td><td><span class="cat">暂未自造</span></td><td>需要真实话题消息 topicId;普通群消息不能替代。</td><td>需要话题群 fixture,或由 IM 同学提供当前账号可访问 topicId。</td></tr>
<tr><td><code>aitable +dashboard-share-get</code></td><td><span class="cat">真实资源仍失败</span></td><td>已有真实 base/dashboard,但 share-get 返回 404 `Failed to get dashboard share config`;更像分享配置未开启或后端接口行为问题。</td><td>需要 AI 表格/后端确认如何创建/开启 dashboard share fixture,或修正 404 语义。</td></tr>
<tr><td><code>write 类 delete/recall/approve/wiki move/copy 等</code></td><td><span class="cat">不自动自造</span></td><td>这些命令即便能造资源,也会涉及删除、撤回、审批通过、知识库移动/复制等高影响动作。</td><td>需要逐项授权和专门测试空间/机器人/审批单据,不建议混在批量回归里自动跑。</td></tr></tbody>
</table>
<h2>Read 失败逐项 <span class="count">· 42 条</span></h2>
<table class="review">
<thead><tr>
<th>#</th><th>命令</th><th>风险</th><th>类型</th><th>要改哪里</th><th>具体改法</th><th>验证方式</th><th>operation</th><th>trace_id</th><th>证据</th>
</tr></thead>
<tbody>
<tr><td class="num">1</td><td><code>aitable +base-get-primary-doc-id</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;-1&quot;,&quot;message&quot;:&quot;no record&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to query cell doc for record 1015oH3OXy in table…</td></tr>
<tr><td class="num">2</td><td><code>aitable +chart-share-get</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +chart-share-get`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;403&quot;,&quot;message&quot;:&quot;Forbidden&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;AUTH_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get chart share config for chart widget-dlxFo…</td></tr>
<tr><td class="num">3</td><td><code>aitable +dashboard-share-get</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +dashboard-share-get`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;404&quot;,&quot;message&quot;:&quot;Not Found&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get dashboard share config for dashboard KY9…</td></tr>
<tr><td class="num">4</td><td><code>aitable +export-data</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;taskId cannot be combined with scope, format, tableId or viewId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,…</td></tr>
<tr><td class="num">5</td><td><code>aitable +record-primary-doc-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;-1&quot;,&quot;message&quot;:&quot;no record&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to query cell doc for record 1015oH3OXy in table…</td></tr>
<tr><td class="num">6</td><td><code>aitable +role-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;roleId is required&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get role because roleId …</td></tr>
<tr><td class="num">7</td><td><code>aitable +workflow-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;GET_WORKFLOW_ERROR&quot;,&quot;message&quot;:&quot;调用远程服务业务异常&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get workflow in base &#x27;gpG2Nd…</td></tr>
<tr><td class="num">8</td><td><code>aitable +workflow-list</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;LIST_WORKFLOWS_ERROR&quot;,&quot;message&quot;:&quot;biz error&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to list workflows in base &#x27;gpG…</td></tr>
<tr><td class="num">9</td><td><code>attendance +get-class</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>2127d89817840997588238831e0757</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_class_detail) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">10</td><td><code>attendance +get-global-setting</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +get-global-setting`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840997604718062e085e</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/query_global_setting) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">11</td><td><code>attendance +get-group</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997620512305e08ef</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_detail) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">12</td><td><code>attendance +get-group-filtered</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实考勤班次/考勤组/员工/假期等资源 ID;当前 no-such ID 只能验证负向路径。</td><td>先用考勤列表/管理后台拿真实 ID,再重跑该 attendance 命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840997638062468e08c7</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_group_filtered_detail) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">13</td><td><code>attendance +get-leave-balance</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `attendance +get-leave-balance` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2104a64c17840997652901307e081a</code></td><td class="evidence">[UNCLASSIFIED] 亲,假期类型没有余额 (operation: attendance-wukong/get_leave_balance_quota) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">14</td><td><code>attendance +list-report-columns</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +list-report-columns`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840997666188472e0756</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_report_columns) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">15</td><td><code>attendance +query-report-leave</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +query-report-leave`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840997679973065e085f</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/get_leave_time_by_leave_names) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">16</td><td><code>calendar +find-room</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入</span></td><td>会议室类命令需要真实 roomId 或更小会议室分组;修改 runner 先定位会议室/分组,再喂给查询命令。</td><td>用真实 roomId/分组重跑 calendar room/freebusy 命令。</td><td><code>tools/call</code></td><td><code>0bb7c36217840997694975303e0758</code></td><td class="evidence">[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">17</td><td><code>calendar +room-find</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入</span></td><td>会议室类命令需要真实 roomId 或更小会议室分组;修改 runner 先定位会议室/分组,再喂给查询命令。</td><td>用真实 roomId/分组重跑 calendar room/freebusy 命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840997709913005e0819</code></td><td class="evidence">[UNCLASSIFIED] 查询范围内的会议室数量,超过上限100,请选择更小范围的分组进行查询。 (operation: calendar/query_available_meeting_room) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">18</td><td><code>chat +category-list-conversations</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +category-list-conversations` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840997724375834e0758</code></td><td class="evidence">[UNCLASSIFIED] listConversationsByCategoryV2 error hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">19</td><td><code>chat +chat-get-by-id</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +chat-get-by-id`。</td><td><code>tools/call</code></td><td><code>2127d89817840997739165535e07bd</code></td><td class="evidence">[UNCLASSIFIED] verifyGroupId error: The group id does not exit (operation: im/get_conv_info_by_group_id) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">20</td><td><code>chat +chat-members-get</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840997754345760e07bd</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required (operation: im/list_group_member_by_ids) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">21</td><td><code>chat +chat-messages</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840997767792656e085e</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">22</td><td><code>chat +messages-list</code></td><td><span class="risk">read</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840997797873841e0757</code></td><td class="evidence">[UNCLASSIFIED] openCid or cid is required hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">23</td><td><code>chat +messages-resource-url</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-resource-url`。</td><td><code>tools/call</code></td><td><code>2127d89817840997855423145e07dd</code></td><td class="evidence">[UNCLASSIFIED] failed to get download url for resourceId: x (operation: im/get_resource_download_url) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">24</td><td><code>chat +search-msg</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997866824643e0853</code></td><td class="evidence">[UNCLASSIFIED] 当前用户暂无消息搜索权益,无法执行本次搜索。请提示用户开通消息搜索权益后重试。 hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">25</td><td><code>chat +thread-replies</code></td><td><span class="risk">read</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +thread-replies`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840997883504915e0853</code></td><td class="evidence">[UNCLASSIFIED] failed to decrypt openConvThreadId hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">26</td><td><code>contact +get-roster</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `contact +get-roster`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2106d98117840997920166915e087b</code></td><td class="evidence">[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/get_authorized_emp_rosterInfo) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">27</td><td><code>contact +list-roster-fields</code></td><td><span class="risk">read</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `contact +list-roster-fields`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>213ee25c17840997934295673e08e2</code></td><td class="evidence">[UNCLASSIFIED] 操作人无花名册管理权限 (operation: hrmregister/list_authorized_roster_fields) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">28</td><td><code>devapp +credentials-get</code></td><td><span class="risk">read</span></td><td><span class="cat">敏感/高风险暂缓</span></td><td><span class="owner">人工安全确认</span></td><td>该项涉及敏感读取或无安全负向目标,不适合自动用真实资源跑;需要在安全环境逐项人工确认。</td><td>人工确认后单独重跑 `devapp +credentials-get`,并避免在报告中泄露密钥/凭证。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">该命令会读取真实应用凭证/密钥;不能用真实 app 自动执行。当前仅用占位 ID 验证负向路径,真实成功需人工在安全环境单独确认。</td></tr>
<tr><td class="num">29</td><td><code>drive +download</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `drive +download` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bab027317840997956923965e08c9</code></td><td class="evidence">[UNCLASSIFIED] 该文件类型不支持通过 download_file 下载。download_file 仅支持普通文件(如 PDF、Word、Excel 等),不支持钉钉在线文档/表格/脑图等在线编辑类型。如需导出在线文档内容,请使用钉钉文档导出相关接口。 (operation: drive/download_file) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">30</td><td><code>drive +list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `drive +list` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2106d98117840997968627612e087b</code></td><td class="evidence">[UNCLASSIFIED] parentId 不属于指定的 spaceId,请确认 parentId 和 spaceId 属于同一个钉盘空间。 hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">31</td><td><code>minutes +action-items</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
<tr><td class="num">32</td><td><code>minutes +latest-minutes</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
<tr><td class="num">33</td><td><code>minutes +minutes-search</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没搜到妙记</td></tr>
<tr><td class="num">34</td><td><code>minutes +transcript</code></td><td><span class="risk">read</span></td><td><span class="cat">缺妙记 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备当前账号可见的真实妙记/听记/录制会话,或把 runner 的搜索关键词改成必然能命中的会议产物。</td><td>用真实 taskUuid/note/minutes 资源重跑 minutes 命令。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无妙记</td></tr>
<tr><td class="num">35</td><td><code>oa +done-approvals</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有已处理的审批记录</td></tr>
<tr><td class="num">36</td><td><code>oa +pending</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">当前没有待我审批的任务</td></tr>
<tr><td class="num">37</td><td><code>report +report-latest</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">暂无日志</td></tr>
<tr><td class="num">38</td><td><code>todo +due-today</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">今天没有到期的待办</td></tr>
<tr><td class="num">39</td><td><code>todo +related-tasks</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有与你相关的待办(creator/executor/participant 三种角色下均为空)</td></tr>
<tr><td class="num">40</td><td><code>wiki +node-list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2132f5ca17840998189126304e08d9</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">41</td><td><code>wiki +resolve-space</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试数据</span></td><td>准备能命中的真实数据,或把 runner 查询词改成当前账号一定存在的对象;shortcut 本身不需要改。</td><td>造数后重跑,预期从 validation empty result 变为成功。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没有找到名称包含 DWS shortcut 真实测试 的知识空间</td></tr>
<tr><td class="num">42</td><td><code>wiki +space-list</code></td><td><span class="risk">read</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `wiki +space-list` 并比较 stdout/stderr。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">参数 --type 取值 &quot;ALL&quot; 不合法,允许值:orgWikiSpace, myWikiSpace</td></tr>
</tbody>
</table>
<h2>Write 失败逐项 <span class="count">· 114 条</span></h2>
<table class="review">
<thead><tr>
<th>#</th><th>命令</th><th>风险</th><th>类型</th><th>要改哪里</th><th>具体改法</th><th>验证方式</th><th>operation</th><th>trace_id</th><th>证据</th>
</tr></thead>
<tbody>
<tr><td class="num">1</td><td><code>aitable +advperm-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +advperm-disable`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to set adv…</td></tr>
<tr><td class="num">2</td><td><code>aitable +advperm-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `aitable +advperm-enable`;若仍是 permission,再看 trace_id。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to set adv…</td></tr>
<tr><td class="num">3</td><td><code>aitable +attachment-upload</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;BASE_NOT_FOUND&quot;,&quot;message&quot;:&quot;Specified base does not exist, has been deleted, or is inaccessible&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:t…</td></tr>
<tr><td class="num">4</td><td><code>aitable +base-copy</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:null,&quot;message&quot;:&quot;Invalid source baseId: DWSREALTESTNOSUCHID0000000000000&quot;,&quot;retryable&quot;:null,&quot;type&quot;:&quot;USER_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Invalid sou…</td></tr>
<tr><td class="num">5</td><td><code>aitable +base-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +base-delete`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;52600003&quot;,&quot;message&quot;:&quot;Data not found&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete base DWSREALTESTNOSUCHID000…</td></tr>
<tr><td class="num">6</td><td><code>aitable +base-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;BASE_NOT_FOUND&quot;,&quot;message&quot;:&quot;Specified base does not exist, has been deleted, or is inaccessible&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:t…</td></tr>
<tr><td class="num">7</td><td><code>aitable +chart-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete …</td></tr>
<tr><td class="num">8</td><td><code>aitable +chart-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">9</td><td><code>aitable +chart-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;config is required&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update chart because con…</td></tr>
<tr><td class="num">10</td><td><code>aitable +dashboard-arrange</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to align d…</td></tr>
<tr><td class="num">11</td><td><code>aitable +dashboard-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete …</td></tr>
<tr><td class="num">12</td><td><code>aitable +dashboard-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">13</td><td><code>aitable +dashboard-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">14</td><td><code>aitable +field-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;404&quot;,&quot;message&quot;:&quot;getDentryDTO returns null&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get current field info befor…</td></tr>
<tr><td class="num">15</td><td><code>aitable +field-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;404&quot;,&quot;message&quot;:&quot;getDentryDTO returns null&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to get current field info for u…</td></tr>
<tr><td class="num">16</td><td><code>aitable +form-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete …</td></tr>
<tr><td class="num">17</td><td><code>aitable +form-field-hide</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">18</td><td><code>aitable +form-field-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">19</td><td><code>aitable +form-share-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">20</td><td><code>aitable +form-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">21</td><td><code>aitable +import-data</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `aitable +import-data`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_IMPORT_ID&quot;,&quot;message&quot;:&quot;importId not found: either invalid or expired&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;impo…</td></tr>
<tr><td class="num">22</td><td><code>aitable +import-upload</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;无法解析 baseId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;无效的 baseId&quot;,&quot;trace_id&quot;:&quot;0bab027317840998…</td></tr>
<tr><td class="num">23</td><td><code>aitable +record-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;404&quot;,&quot;message&quot;:&quot;getDentryDTO returns null&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete records&quot;,&quot;trace_id&quot;:&quot;…</td></tr>
<tr><td class="num">24</td><td><code>aitable +record-primary-doc-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;RESOLVE_DOC_ID_ERROR&quot;,&quot;message&quot;:&quot;Failed to resolve docId from baseId&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to c…</td></tr>
<tr><td class="num">25</td><td><code>aitable +record-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_RECORDS&quot;,&quot;message&quot;:&quot;records must contain at least one writable record&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Fa…</td></tr>
<tr><td class="num">26</td><td><code>aitable +record-upsert</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMETER&quot;,&quot;message&quot;:&quot;records is required and must not be empty&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;records …</td></tr>
<tr><td class="num">27</td><td><code>aitable +role-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to create …</td></tr>
<tr><td class="num">28</td><td><code>aitable +role-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;roleId is required&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete role because role…</td></tr>
<tr><td class="num">29</td><td><code>aitable +role-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;roleId is required&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to patch role because roleI…</td></tr>
<tr><td class="num">30</td><td><code>aitable +section-create</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to create …</td></tr>
<tr><td class="num">31</td><td><code>aitable +section-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete …</td></tr>
<tr><td class="num">32</td><td><code>aitable +section-move-node</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to move no…</td></tr>
<tr><td class="num">33</td><td><code>aitable +section-rename</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to rename …</td></tr>
<tr><td class="num">34</td><td><code>aitable +section-reorder</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to reorder…</td></tr>
<tr><td class="num">35</td><td><code>aitable +table-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;BASE_NOT_FOUND&quot;,&quot;message&quot;:&quot;Specified base does not exist, has been deleted, or is inaccessible&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:t…</td></tr>
<tr><td class="num">36</td><td><code>aitable +table-update</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;404&quot;,&quot;message&quot;:&quot;getDentryDTO returns null&quot;,&quot;retryable&quot;:true,&quot;type&quot;:&quot;SYSTEM_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update table DWSREALTESTNOSU…</td></tr>
<tr><td class="num">37</td><td><code>aitable +view-delete</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to delete …</td></tr>
<tr><td class="num">38</td><td><code>aitable +view-duplicate</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to duplica…</td></tr>
<tr><td class="num">39</td><td><code>aitable +view-lock</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to lock_or…</td></tr>
<tr><td class="num">40</td><td><code>aitable +view-set-fill-color-rule</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>修 aitable MCP wrapper 的参数校验和错误语义:不要返回 success=true+error;对 required 字段给出 CLI 可识别的参数名,系统错误要带 retryable/trace。</td><td>MCP 修完后重跑该 aitable 命令,并确认 stdout JSON 不再出现 success=true 但 error 非空。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_PARAMS&quot;,&quot;message&quot;:&quot;conditionalFormats is required&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to set fill col…</td></tr>
<tr><td class="num">41</td><td><code>aitable +view-set-frozen-cols</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to set fro…</td></tr>
<tr><td class="num">42</td><td><code>aitable +view-set-row-height</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to set cel…</td></tr>
<tr><td class="num">43</td><td><code>aitable +view-update</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;INVALID_BASE_ID&quot;,&quot;message&quot;:&quot;baseId cannot be resolved to docId&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;status&quot;:&quot;error&quot;,&quot;success&quot;:true,&quot;summary&quot;:&quot;Failed to update …</td></tr>
<tr><td class="num">44</td><td><code>aitable +workflow-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;BASE_NOT_FOUND&quot;,&quot;message&quot;:&quot;Cannot resolve base &#x27;DWSREALTESTNOSUCHID0000000000000&#x27;, please check if the baseId is valid&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;sta…</td></tr>
<tr><td class="num">45</td><td><code>aitable +workflow-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">缺 AI 表格 fixture</span></td><td><span class="owner">测试数据</span></td><td>准备真实 Base/Table/View/Field/Record/Role/Chart/Dashboard 等 fixture,并把真实 ID 写入真实测试 runner;当前安全负向 ID 只能证明调用链,不可能成功。</td><td>fixture 准备好后重跑对应 aitable 命令;预期从 not_found/invalid_base_id 变为成功或更具体业务错误。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">[MCP_TOOL_ERROR] {&quot;data&quot;:{},&quot;error&quot;:{&quot;code&quot;:&quot;BASE_NOT_FOUND&quot;,&quot;message&quot;:&quot;Cannot resolve base &#x27;DWSREALTESTNOSUCHID0000000000000&#x27;, please check if the baseId is valid&quot;,&quot;retryable&quot;:false,&quot;type&quot;:&quot;INPUT_ERROR&quot;},&quot;meta&quot;:{},&quot;sta…</td></tr>
<tr><td class="num">46</td><td><code>attendance +boss-check</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>213ee25c17840998998942184e087d</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/boss_check) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">47</td><td><code>attendance +create-class</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0bab027317840999009926838e090b</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_class_setting) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">48</td><td><code>attendance +create-group</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2106d98117840999021121258e08b8</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/create_group_setting) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">49</td><td><code>attendance +import-schedule</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2104a64c17840999034845189e085e</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/generateTurnSchedule) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">50</td><td><code>attendance +save-leave-balance</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +save-leave-balance`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999045751134e079c</code></td><td class="evidence">[UNCLASSIFIED] 无权更新指定员工的假期余额 (operation: attendance-wukong/update_leave_balance) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">51</td><td><code>attendance +update-class</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999058236471e08b5</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_class_setting) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">52</td><td><code>attendance +update-group</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999068826691e087a</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_setting) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">53</td><td><code>attendance +update-group-members</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2127d89817840999081094644e07dd</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: attendance-wukong/update_group_member) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">54</td><td><code>attendance +update-leave-type</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `attendance +update-leave-type`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999093924897e07fe</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: attendance-wukong/save_leave_type) hint: Check if the resource exists or if your account has permission</td></tr>
<tr><td class="num">55</td><td><code>calendar +respond-event</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `calendar +respond-event`。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999105062121e07db</code></td><td class="evidence">[UNCLASSIFIED] code: 300000, developerMessage: Event does not exist. (operation: calendar/respond) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">56</td><td><code>chat +category-add-conversation</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +category-add-conversation`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999117776203e08f9</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/add_conv_to_categories) hint: Check if the resource exists or if your account has permission</td></tr>
<tr><td class="num">57</td><td><code>chat +category-remove-conversation</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +category-remove-conversation`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999130466520e085e</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: im/remove_conv_from_categories) hint: Check if the resource exists or if your account has permission</td></tr>
<tr><td class="num">58</td><td><code>chat +chat-add-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `chat +chat-add-bot`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999144193460e08c7</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: bot/add_robot_to_group) hint: Check if the resource exists or if your account has permission</td></tr>
<tr><td class="num">59</td><td><code>chat +chat-audit-join</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999154832733e0758</code></td><td class="evidence">[UNCLASSIFIED] applicantUid is required (operation: im/audit_join_group) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">60</td><td><code>chat +chat-mute-member</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999166036583e08a3</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_group_member_mute_list) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">61</td><td><code>chat +chat-quit</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-quit` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999176728426e0779</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/quit_group) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">62</td><td><code>chat +chat-remove-bot</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +chat-remove-bot`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999189888305e08b5</code></td><td class="evidence">[UNCLASSIFIED] 无效的会话 (operation: bot/remove_robot_in_group) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">63</td><td><code>chat +chat-role-remove</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-role-remove` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999200483204e079c</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_group_role) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">64</td><td><code>chat +chat-role-remove-user</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-role-remove-user` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999211317952e0757</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/remove_custom_user_roles) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">65</td><td><code>chat +chat-transfer-owner</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999222318863e087a</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/transfer_group_owner) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">66</td><td><code>chat +chat-update-icon</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `chat +chat-update-icon` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2104a64c17840999232478654e0819</code></td><td class="evidence">[UNCLASSIFIED] listBaseConversationByIds error (operation: im/update_group_icon) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">67</td><td><code>chat +chat-update-settings</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/shortcut 枚举</span></td><td>把测试输入的 setting-key 从 x 改为后端支持的 key;同时可在 shortcut flag 上补 enum,避免用户传非法 key。</td><td>改 runner 后重跑;如果补 enum,跑 shortcut 单测确认校验文案。</td><td><code>tools/call</code></td><td><code>2127d89817840999244326971e07dd</code></td><td class="evidence">[UNCLASSIFIED] unsupported setting key: x (operation: im/update_group_settings) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">68</td><td><code>chat +conversation-clear-messages</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999254816721e079b</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_messages) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">69</td><td><code>chat +conversation-clear-red-point</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999265511295e085f</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/clear_conversation_red_point) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">70</td><td><code>chat +conversation-hide</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999276117140e079b</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/hide_conversation) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">71</td><td><code>chat +conversation-mark-read</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2127d89817840999287654280e07bd</code></td><td class="evidence">[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/mark_message_read) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">72</td><td><code>chat +conversation-mark-unread</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999298744910e0758</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/mark_conversation_unread) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">73</td><td><code>chat +conversation-mute</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999309241865e085f</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_notification_off) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">74</td><td><code>chat +conversation-mute-at-all</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999320028068e07dd</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_at_all_notification_off) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">75</td><td><code>chat +conversation-mute-red-envelope</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999330228283e07fe</code></td><td class="evidence">[UNCLASSIFIED] openConversationId is required (operation: im/update_red_env_notification_off) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">76</td><td><code>chat +conversation-set-top</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2127d89817840999341302961e07fe</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_top_conversation) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">77</td><td><code>chat +messages-add-emoji</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2127d89817840999352941910e0756</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_emoji_reaction) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">78</td><td><code>chat +messages-add-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999362862711e08b5</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/add_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">79</td><td><code>chat +messages-batch-recall-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-batch-recall-by-bot`。</td><td><code>tools/call</code></td><td><code>2104a64c17840999373456305e0817</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/batch_recall_robot_users_msg) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">80</td><td><code>chat +messages-batch-send-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-batch-send-by-bot`。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999385748776e0757</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/batch_send_robot_msg_to_users) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">81</td><td><code>chat +messages-combine-forward</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2104a64c17840999396596163e08ee</code></td><td class="evidence">[UNCLASSIFIED] srcOpenCid无效,无法解析为cid (operation: im/combine_forward_messages) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">82</td><td><code>chat +messages-create-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务规则</span></td><td>换成后端支持的文字表情组合,或把该命令保留为业务负向;CLI 不应绕过后端限制。</td><td>用一个真实可保存的表情模板重跑。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999407422891e08cd</code></td><td class="evidence">[UNCLASSIFIED] 暂不支持保存该文字表情 (operation: im/create_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">83</td><td><code>chat +messages-forward</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840999417966407e08ee</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">84</td><td><code>chat +messages-forward-topic</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2127d89817840999428541474e07dd</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/forward_topic) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">85</td><td><code>chat +messages-recall</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为当前账号真实可访问的群/会话 openConversationId;如果用真实群仍报“无效”,再查 IM 后端解析。</td><td>先用 `chat +my-groups` 或群搜索拿真实会话 ID,再重跑。</td><td><code>tools/call</code></td><td><code>2104a64c17840999441138103e08c7</code></td><td class="evidence">[UNCLASSIFIED] openConversationId无效,无法解析为cid (operation: im/recall_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">86</td><td><code>chat +messages-recall-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2104a64c17840999454382709e08a3</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/recall_robot_group_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">87</td><td><code>chat +messages-remove-emoji</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999467733059e08f9</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_emoji_reaction) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">88</td><td><code>chat +messages-remove-text-emotion</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999478923406e087f</code></td><td class="evidence">[UNCLASSIFIED] invalid openMsgId: DWSREALTESTNOSUCHID0000000000000 (operation: im/remove_text_emotion) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">89</td><td><code>chat +messages-send-by-bot</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `chat +messages-send-by-bot`。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999494005921e08ef</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: bot/send_robot_group_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">90</td><td><code>chat +messages-send-card</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999509255753e081a</code></td><td class="evidence">[UNCLASSIFIED] receiverUid和openConversationId不能同时为空 (operation: im/create_and_send_card) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">91</td><td><code>chat +messages-set-pin</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999521117991e0758</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/set_pin_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">92</td><td><code>chat +messages-set-top</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>2104a64c17840999532438476e0817</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/set_top_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">93</td><td><code>chat +messages-unset-pin</code></td><td><span class="risk">write</span></td><td><span class="cat">后端/MCP</span></td><td><span class="owner">后端/MCP schema</span></td><td>CLI fake MCP 已证明字段已装配;需要修 MCP tool schema 或网关字段映射,确认 openConversationId/openCid/cid、receiverUid、applicantUid 等字段没有在 schema 校验/转发时被丢弃。</td><td>修 MCP 后不改 shortcut,直接重跑真实命令;预期错误从 required 变为资源无效或成功。</td><td><code>tools/call</code></td><td><code>2104a64c17840999544428103e08ee</code></td><td class="evidence">[UNCLASSIFIED] openConversationId or cid is required (operation: im/unset_pin_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">94</td><td><code>chat +messages-unset-top</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实 openMessageId,并保证消息属于当前账号可访问会话;当前 no-such ID 只能验证负向路径。</td><td>先用消息列表拿 messageId,再重跑消息详情/状态/撤回类命令。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999554154221e08f9</code></td><td class="evidence">[UNCLASSIFIED] openMessageId解密失败 (operation: im/unset_top_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">95</td><td><code>devapp +event-subscribe</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +event-subscribe`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999564022020e08c7</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有应用事件订阅权限 (operation: devapp/subscribe_dev_app_events) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">96</td><td><code>devapp +event-unsubscribe</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +event-unsubscribe`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999575698500e07db</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/unsubscribe_dev_app_events) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">97</td><td><code>devapp +permission-add</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +permission-add`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999587758457e079c</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/apply_dev_app_permissions) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">98</td><td><code>devapp +permission-remove</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +permission-remove`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999598445247e085e</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/remove_dev_app_permissions) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">99</td><td><code>devapp +robot-config</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-config`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999609828961e07db</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/set_extension_robot_config) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">100</td><td><code>devapp +robot-disable</code></td><td><span class="risk">high-risk-write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-disable`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999620461113e08ee</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/disable_dev_app_robot) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">101</td><td><code>devapp +robot-enable</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +robot-enable`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999632641525e0758</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/enable_dev_app_robot) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">102</td><td><code>devapp +security-config</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `devapp +security-config`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2104a64c17840999645522046e0817</code></td><td class="evidence">[UNCLASSIFIED] 当前用户没有开发者身份 (operation: devapp/update_dev_app_security_config) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">103</td><td><code>devapp +version-create</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999656705920e0853</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/create_dev_app_version) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">104</td><td><code>devapp +version-publish</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">后端业务/测试 fixture</span></td><td>后端只返回 success=false,信息不足;先准备真实合法 fixture,若仍无细节,需要后端补充错误码/错误信息。</td><td>用真实资源重跑;若仍 success=false,把 operation+trace_id 给后端。</td><td><code>tools/call</code></td><td><code>2127d89817840999667906017e075d</code></td><td class="evidence">[UNCLASSIFIED] business error: success=false (operation: devapp/publish_dev_app_version) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">105</td><td><code>ding +send-by-message</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `ding +send-by-message` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0b5deb3217840999678466213e0853</code></td><td class="evidence">[UNCLASSIFIED] remindType非法,合法值:APP/SMS/PHONE (operation: im/send_ding_by_message) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">106</td><td><code>doc +comment-create-inline</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>2127d89817840999689931893e079c</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc-comment/create_inline_comment) hint: Document may have been deleted or moved</td></tr>
<tr><td class="num">107</td><td><code>doc +template-apply</code></td><td><span class="risk">write</span></td><td><span class="cat">鉴权/权限</span></td><td><span class="owner">权限/应用配置</span></td><td>给当前登录账号、DWS 应用或对应资源补齐权限/scope;本仓库 shortcut 不应绕过权限。拿 trace_id 给服务端/开放平台排查具体 scope。</td><td>补权限后重跑 `doc +template-apply`;若仍是 permission,再看 trace_id。</td><td><code>tools/call</code></td><td><code>2127d89817840999701186954e0757</code></td><td class="evidence">[RESOURCE_NOT_FOUND] Requested resource not found (operation: doc/apply_doc_template) hint: Check if the resource exists or if your account has permission</td></tr>
<tr><td class="num">108</td><td><code>minutes +record-pause</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-pause` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999713124987e0757</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">109</td><td><code>minutes +record-resume</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-resume` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999724452848e0758</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">110</td><td><code>minutes +record-stop</code></td><td><span class="risk">write</span></td><td><span class="cat">输入/业务校验</span></td><td><span class="owner">测试输入/业务校验</span></td><td>当前命令已进入后端业务校验;先把测试输入换成真实合法 fixture,再判断是否需要改 shortcut。</td><td>重跑 `minutes +record-stop` 并比较 stdout/stderr。</td><td><code>tools/call</code></td><td><code>2127d89817840999735397508e0757</code></td><td class="evidence">[UNCLASSIFIED] aiAgentTestRunCmdUnknownError (operation: minutes/执行听记指令-发起AI听记录音) hint: Use --verbose for detailed error logs</td></tr>
<tr><td class="num">111</td><td><code>oa +approve-by</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `oa +approve-by`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没找到待审批单据:待我处理的审批里没有标题/单号包含 &quot;__DWS_SHORTCUT_REAL_TEST_NO_SUCH_APPROVAL_20260715-151724__&quot; 的单据。</td></tr>
<tr><td class="num">112</td><td><code>wiki +node-copy</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999769818495e0779</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc/copy_document) hint: Document may have been deleted or moved</td></tr>
<tr><td class="num">113</td><td><code>wiki +node-move</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>替换为真实文档/节点 ID;文档评论、分享、版本等命令需要资源存在且账号可访问。</td><td>用真实 doc/node 重跑;若仍失败再看 doc/doc-comment 工具字段。</td><td><code>tools/call</code></td><td><code>0bb7c36217840999780286509e07fe</code></td><td class="evidence">[TABLE_NOT_FOUND] Requested resource not found (operation: doc/move_document) hint: Document may have been deleted or moved</td></tr>
<tr><td class="num">114</td><td><code>wiki +wiki-new-doc</code></td><td><span class="risk">write</span></td><td><span class="cat">缺真实资源</span></td><td><span class="owner">测试数据</span></td><td>把 runner 里的安全负向 ID 换成真实资源 ID;当前错误说明调用已进后端,但资源不存在。</td><td>准备 fixture 后重跑 `wiki +wiki-new-doc`。</td><td><code>-</code></td><td><code>-</code></td><td class="evidence">没找到名为 &quot;__DWS_SHORTCUT_REAL_TEST_NO_SUCH_SPACE__&quot; 的知识库;换个更完整/精确的空间名再试。</td></tr>
</tbody>
</table>
</main>
</body>
</html>
+223
View File
@@ -0,0 +1,223 @@
# DWS Shortcut — 方法论与进展交接文档(换会话续跑用)
> 目的:换新会话直接照此续跑。记录**方法论、已完成进展、如何继续、关键坑位、验证命令**。
> 分支:`feature/shortcut`(**改动全部未提交**,commit 由用户主动决定)。
---
## 0. 一句话现状
> 2026-07-09 **去冗余(重大修订)**:复盘发现 `internal/helpers/` 早有 ~697 个 `dws <svc> <verb>` 产品命令封装了 281 个 tool;1:1 shortcut 层有 235 个 tool 与之重复。按「tool 已被 helper 封装 且 shortcut 用 CallMCP 无投影」精确删除 **213 条纯重复 shortcut**,1:1 层 511→298、总数 579→366、服务 19→16(aisearch/live/devdoc 整包移除,其 dws 命令仍由 helper 提供)。全绿+真机复验保留命令可用。**教训:建封装层前先审已有封装。**
在 `internal/shortcut/` 下建成一套声明式 shortcut 体系:**366 条命令 = 298 条 1:1 封装 + 68 条真·智能编排**(1:1 层原 511,已删 213 条纯重复——helper 层早已封装同一批 tool),另有 **~60 条封装升级到 lark 输出投影保真度**、P2 高频自动沉淀闭环、深度对齐 lark 矩阵。**全绿**(build/gofmt/vet/shortcut 全量测试 `shortcuts=366 assembled=322 validated=44 failed=0`/app 全量回归 72s/真机抽验)。
> 2026-07-09 批33(净新增 1 条·+conflicts 的互补品):`calendar +free-slots`(找某天工作时段内的空闲时段,list_calendar_events + 合并忙碌区间 + 工作窗口求补集,默认今天 09:00-18:00/--from/--to/--in-days)。真机验证:今日 4 段空档(09:00-09:15/15min、12:00-13:30/90min、14:00-14:30/30min、16:00-17:15/75min),正是忙碌事件的精确补集。conflicts+free-slots 构成真实排期智能。总数 578→579、smart 67→68。全绿。
> 2026-07-09 批32b(净新增 1 条·dws 原生编排,lark 也没有):`calendar +conflicts`(检测某天日程时间冲突/双重预订,list_calendar_events + 本地两两 [start,end) 重叠检测,默认今天/--in-days)。真机验证:抓到今日 9 个日程里 2 处真实冲突(技术标评审 10:00-11:00 × AIX 共创 10:30-12:00;尖角班 14:30-15:30 × 中控项目 15:00-16:00)。证明复杂写死胡同之外,纯 MCP-tool 的本地编排仍有净新增价值。总数 577→578、smart 66→67。全绿。
> 2026-07-09 批32(review lark 复杂写类 + 架构边界结论,用户指定方向):fresh review lark 复杂写 shortcut(mail +send/+reply/+forward、drive +import、doc +media-insert/download、base +record-upload-attachment),交叉 dws helpers。**关键结论——架构性死胡同,非没使劲**:① `mail +send` dws 已有 1:1 `+send`(send_email)+`+draft-*`,且 **contact 无 email 字段**→无法按名解析收件人(矩阵早 skip),也无 signature/template/lint 工具;② `drive +import`/`doc +media-*`/`base +record-upload-attachment` 核心是**本地文件字节 PUT/下载落盘**——dws 里由 helper 内部 `httpPutFile`/`http PUT/GET`(drive.go/doc.go)实现、已在 1:1 层覆盖(如 `drive upload --convert`),但 **shortcut 框架 `rt.CallMCP/CallMCPData` 只编排 MCP tool、结构上做不了原始文件 I/O**,故无法在 smart 层组合。**建议**:剩余复杂写要么卡此边界、要么已被 1:1 覆盖;真要补文件类能力应在 helper/1:1 层加命令,而非 shortcut 层。本批 review、无代码改动,总数仍 577。注:本轮触及 session 限额(8:20pm 重置)+ 分类器一度不可用,Bash 受限。
> 2026-07-09 批31(净新增 1 条):`calendar +my-free`(我自己的忙闲,自动解析当前 userId、默认今天,复用 +free 的 freebusySlots 投影;无需像 +free 传别人姓名)。真机正向验证:返回今日/明日真实忙碌时段 {busy:[{start,end}],userId,free}。总数 576→577、smart 65→66。文档全量同步。全绿。
> 2026-07-09 批30(净新增 1 条):`contact +me`(当前用户 `get_current_user_profile` + 投影 `{name,userId,mobile,dept,org,email}`,agent 的「我是谁」;区别于 1:1 `+get-self` 吐冗长 `result[].orgEmployeeModel` raw)。真机正向验证:董鑫阳/202397/模型算法/钉钉。总数 575→576、smart 64→65。文档全量同步。全绿。
> 2026-07-09 批29(净新增便利读 3 条 + 真机抓修 1 bug):`oa +done-approvals`(审批历史 get_done_tasks)、`mail +recent-mail`(近期收件 list_mailbox_threads + 解析绑定邮箱/收件箱 folder)、`attendance +this-month`(本月打卡 query_check_record on attendance-wukong,复用 +my-attendance)。**真机抓到并修复 bug**:`+done-approvals` 原来 --limit 不传时 pageSize=0 → 后端 business error(1:1 list-executed 有默认所以正常);改为默认 pageSize=20,真机复验走空路径「没有已处理的审批记录」。+this-month 真机有效空、+recent-mail 正确报未绑定邮箱。总数 572→575、smart 61→64。文档全量同步。全绿。
> 2026-07-09 批28(净新增便利读 smart,多 agent 并行 + 手工):再建 3 条只读 smart——`oa +pending`(`list_pending_approvals` 只读列待我审批,区别于会审批的 +approve-by)、`todo +due-today`(`get_user_todos_in_current_org` + `planFinishDateStart/End` 服务端过滤今天到期,区别于 +overdue 已过期)、`calendar +tomorrow`(明天日程,复用 +today/+week 投影)。真机:+tomorrow 返回真实明日日程;+pending/+due-today 空路径正确且复用已验证 helper。3 条为 dws 原生便利读、不对应 lark gap,矩阵 42/48 不变,总数 569→572、smart 58→61。文档全量同步。全绿。
> 2026-07-09 批27(写类输出扫荡收尾,确认无更多 bug):扫 smart 里丢弃 CallMCPData 结果的 3 处——`broadcast`(per-recipient 循环、最终结构化输出,OK)、`book`(弃 add-participant 结果但最终 `get_calendar_detail` 确认 + 失败回滚,OK)、`reschedule`(弃存在性 check detail 是有意的,随后打 update 结果,OK)。**无更多 silent-success bug**。结论:**输出质量扫荡完成**,只读投影 clean、写类确认结果、honor --format。剩余仅复杂 net-new gap-buildable(mail +send/drive +import 等多步写、难安全真机验)或 commit。真机+一致性改进累计 13 条,总数 569,全绿。
> 2026-07-09 批26(写类 smart 输出一致性批量修):扫 smart 里用 `fmt.Print*` / 无标准输出的。修 2 条:`chat +broadcast`(`fmt.Printf` 群发摘要忽略 --format → `rt.Output({sentCount,failedCount,sent,failed})`);`wiki +wiki-new-doc`(**原创建文档后丢弃 create_file 结果、静默 return nil**,真 UX bug 拿不到新文档 id/url → 捕获并 `rt.Output({created,space,title,result})`)。写类无法真机验(会真建/发),assemble 测试确认组装正确、低风险。总数仍 569。
> 2026-07-09 批25(+next-event 输出一致性修复 + sweep 确认多数已 clean):sweep 探 chat +conversation-list/+category-list、aitable +base-list 等——**多数 1:1 只读命令输出已 clean**(属先前 ~60 升级覆盖),保真度工作基本到位。**修复 1 条一致性**:`calendar +next-event` 原用 `fmt.Println` 打固定文本行、**忽略 --format/--jq/--fields**,改为 `rt.Output(map{event:项目投影})`(复用 +today/+week 同款投影),真机复验 `--format json` 出结构化 `{event:{title,start,end,location,eventId}}`、`--jq '.event.title'` 可用。这是 Agent 友好性修复。同时删除死代码 `shortcutNextEventSummary` + 无用 fmt import。总数仍 569。
> 2026-07-09 批24(1:1 层保真度升级续):`contact +list-followings` 原 `rt.CallMCP` 吐 `{arguments,result:{models:[…]}}` 信封噪音,改为 `CallMCPData`+`listFollowingsProject`,真机复验干净 `{count:13, followings:[{openDingTalkId}]}`。总数仍 569。**判断**:真机验证 + 保真度升级已到深度边际收益区(本批仅拍平 ID 列表);1:1 层多数只读命令要么需特定参数、要么后端权限受限、要么输出已可接受。**建议优先 commit 留存 24 批成果**(10 个真机改进 + 58 smart + 保真度升级 + P2 + 全套文档),再按需推进剩余 1:1 微升级。
> 2026-07-09 批23(验证驱动的 1:1 层保真度升级起步):真机探 1:1 只读命令,`drive +recent` 原 `rt.CallMCP` 吐冗长 raw(logId/nextCursor 噪音 + 每项巨型 docUrl + hasMore),改为 `CallMCPData`+`recentListProject`:投影 `{count, hasMore, items:[{name,nodeType,contentType,accessTime,docUrl,nodeId}], nextCursor}`,去 logId 噪音、保留分页与链接,真机复验干净。`drive +list-spaces` 真机空(有 errorCode 包裹噪音但 result.items 为空,暂不动)。总数仍 569。**注**:1:1 层仍有数十个 list 命令可类似升级,但属边际收益、量大,建议按需/被动推进,优先 commit 留存已有成果。
> 2026-07-09 批22(报告综合更新,反映真机验证战役):给 `shortcut-report.md` 新增 §2.4「真机验证战役」:记录 9 批真机验证(正向验证 20+ 条、抓修 8 个真实 bug 的表格、后端受限项、resolveUser 非 bug 澄清);`shortcut-report.html` §③ 测试表补 2 行 + 一段说明。诚实反映「assemble 合成测试盲区 → 真机验证补齐」的价值。纯文档,无代码改动,总数仍 569。
> 2026-07-09 批21(find-record 验证 + +suggest-time 保真度升级):`aitable +find-record` 真机正常(返回真实记录;cells 按字段 ID 键值、内含附件对象,天然复杂,clean 投影需 field-id→name 解析属更大改造,暂留)。**升级 1 条**:`calendar +suggest-time` 原 `rt.CallMCP` 吐 `result.recommendEventTimes[]` 且 `timeConflictAttendees:[null]` 噪音,改为 `CallMCPData`+`suggestTimeSlots`+`Output`:拍平 result、丢弃 null 冲突项,真机复验干净 `{suggestions:[{start,end}]}`(有真实冲突时才带 conflicts)。总数仍 569。**说明**:真机验证扫荡已进入边际收益递减区(明显 raw-verbose 的 wart 基本清完),剩余多为 minutes org-gated、写类、或输出已可接受。列出 12 条只读仍用 raw `rt.CallMCP` 的 smart(多为 minutes org-gated 或已验证 clean)。**升级 1 条**:`calendar +today` 原直吐 17 字段冗长事件(含完整 attendees 数组),改为 `CallMCPData`+复用 `+week` 的 `shortcutNextEventList/Start` 投影,真机复验干净输出 `{events:[{title,start,end,location,eventId}]}`(与 +week 一致 + location)。总数仍 569。剩余 raw-CallMCP 只读 smart:action-items/latest-minutes/transcript(minutes org-gated 无法真机验)、org/report-latest(已验 clean)、find-record/suggest-time/by-mobile/lookup/team(待验或权限受限)。
> 2026-07-09 批19(日历只读 smart 验证 + +free 保真度升级):`calendar +next-event` 真机正常(可读摘要「下一个日程:致拓 AI FDE 经验分享…」,但**忽略 --format json 只吐文本**,已知小瑕疵未改)。**升级 1 条**:`calendar +free` 终结步原 `rt.CallMCP` 直吐冗长 `result[].scheduleItems[].{start,end}.dateTime` 嵌套,改为 `CallMCPData`+`freebusySlots`+`Output`,真机复验干净输出 `{who,userId,free,busy:[{start,end}]}`(董鑫阳 2026-07-10 忙 4 段)。总数仍 569。
> 2026-07-09 批18(真机验证续 + +group-members 保真度升级):**验证正常**:`contact +org`(董鑫阳→模型算法/17人,3步链)、`drive +find-file`(干净投影 {dentryId,fileSize,name,type})。**后端受限(非 bug)**:`contact +team`(列部门成员 `PAT_MEDIUM_RISK_NO_PERMISSION`)、`chat +search-msg`(org 未开 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)。**升级 1 条**:`chat +group-members` 终结步原用 `rt.CallMCP`(直吐原始冗长 `result.list[]` + memberAvatarMediaId + arguments/errorCode 噪音),改为 `CallMCPData`+`groupMemberProject`+`Output`,真机复验干净输出 `{count, members:[{name,nick,role,openDingtalkId}]}`(刘力/怒龙/群主…)。总数仍 569。
> 2026-07-09 批17(修复批16 发现的 +at-me 投影):`chat +at-me` 原来因 `atMeMessageItems` 不认识真实两层嵌套 `result.conversationMessagesList[].messages[]` → 命中 fallback、直接吐原始结构。真机 dump 出真实结构(group 有 title/openConversationId/messages;message 有 sender/content/createTime/openConversationId),新增 `atMeFlattenGroups` 把各会话组拍平成单一消息列表、并把组的会话 title 下沉到每条消息。真机复验:43 条消息干净投影为 `{conversation,sender,text,time}`(如 conversation:"AI全栈"、sender:"龙衔")。总数仍 569。
> 2026-07-09 批16(只读 smart 真机验证扫荡 + 质量修复):真机跑一批时间/自身类只读 smart。**验证正常**:`calendar +today`(真实日程+参会人)、`calendar +week`(干净投影)、`todo +overdue`(空)、`attendance +my-attendance`(空)、`report +report-latest`("暂无日志"空路径)、`oa +my-initiated`(真实审批数据)。**修复 1 个输出 wart**:`chat +unread-chats` 每行都吐 `unread: null`——因 `unread_message_conversation_list` 根本不返回每会话未读数(在列表里即代表未读),改为「仅当 gateway 真返回未读数时才带 unread 字段」,真机复验输出已干净 `{conversationId,name}`。**已知待优化(未改)**:`chat +at-me` 返回 `result.conversationMessagesList[].messages[]` 冗长嵌套原始结构、未拍平成干净消息列表(功能正常,投影可再优化)。总数仍 569。
> 2026-07-09 批15(质量修复 + resolveUser 排查,真机):**修复** `contact +dept-members` 消歧消息 `<red>` 标记泄漏——复用 `stripHighlightTags`(resolve_dept.go)在 name 提取处剥离,真机复验消息已干净("开放平台(666202009)、技术平台-开放平台研发(1085781688)…")。注:`dept_members.go` 本身容器解析(含 deptList)+数值 deptId 早已健壮,仅 name markup 未剥。**排查澄清(非 bug)**:`resolveUser` 对 `董鑫阳` 真机端到端正常(userId 202397、部门 模型算法);但对 `秋画` 这类联系人 `search_contact_by_key_word` 返回 name/userId 全 null(仅 openDingTalkId),resolveUser 正确报「没找到」而非瞎猜——这是钉钉数据模型现实(外部/受限联系人无 userId),非代码 bug。**已知限制**:按名解析仅对「搜索能返回 userId 的组织内成员」有效。总数仍 569。
> 2026-07-09 批14(真机验证续,需具体 ID 的只读 shortcut):**正向验证过**:`chat +my-groups`(98 真实群+投影)、`aitable +base-list`/`+list-tables`(真实 base/table)、`aitable +resolve-table`(单命中 通用→99dV75A、多候选消歧,容器 key `tables` 正确,无 deptList-class bug)。**后端权限受限、无法正向验证(非代码 bug)**:`chat +chat-messages`(`PAT_MEDIUM_RISK_NO_PERMISSION`,读会话消息需更高权限)、`minutes +*`(该 org 未开启 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)。结论:可验证的 read/resolve shortcut 全部投影正确,仅批13 的 resolve-dept 有真 bug 已修。
> 2026-07-09 批13(真机验证 + bug 修复,登录态 corp「钉钉」):用登录态把批9-12 只读 shortcut 打真实后端。**正向验证过**:`doc +find-doc`(10 真实文档、投影干净)、`aitable +resolve-base`(多候选真实 baseId)、`mail +find-mail-user`(命中真实用户+邮箱)、`contact +resolve-dept`(修复后返回真实候选)。**真机抓到并修复 1 个真 bug**:`contact +resolve-dept` 原来对任何真实部门名都返回「未找到」——真实 `search_dept_by_keyword` 响应容器 key 是 **`deptList`**(agent 的探测清单漏了),且 `deptName` 带 `<red>…</red>` 高亮标记、`deptId` 是数值。已修:容器加 `deptList`、`stripHighlightTags` 去标记、deptId 数值 coerce 成串(`resolve_dept.go`),真机复验通过(开放平台→666202009、财务→846624121,名称干净)。**已知遗留(未改)**:`contact +dept-members` 的消歧提示消息里 `<red>` 标记未剥离(仅 cosmetic,功能正常)。总数仍 569,本批未加新命令。
> 2026-07-09 批12(多 agent 并行,dws 原生 resolver 层):再建 3 条「按名解析 ID」智能 shortcut——`wiki +resolve-space`(search_wikiSpaces 名→spaceId)、`aitable +resolve-table`(get_tables 在 Base 内本地名→tableId)、`contact +resolve-dept`(search_dept_by_keyword 名→deptId,**已修数值 ID 兼容**:deptId 为 JSON number 时 coerce 成串,非 string-only)。均 0/1/多候选消歧,对标 resolveUser 各资源版。这 3 条不对应具体 lark gap(是 dws 原生便利层),故 gap-buildable/covered-smart 矩阵计数不变(42/48),仅总数 566→569、smart 55→58。文档全量同步。全绿。
> 2026-07-09 批11(多 agent 并行):再建 3 条智能 shortcut——`aitable +resolve-base`(search_bases 按名解析 baseId + 0/1/多候选消歧)、`chat +chat-messages`(群/单聊会话消息 list_conversation_message_v2 / list_individual_chat_message,ExactlyOne 互斥 + 投影)、`mail +find-mail-user`(search_mail_users 按名搜企业邮箱联系人 + 投影)。文档全量同步 566/505/55(gap-buildable 49→42、covered-smart→48)。全绿。注:本批 app 回归首跑因并发负载 flaky FAIL 一次(80s),连跑 2 次稳定 PASS(71s)——非本次改动导致。
> 2026-07-09 批10(多 agent 并行):再建 3 条智能 shortcut——`chat +thread-replies`(list_topic_replies 拉话题回复 + sender/text/time 投影)、`todo +related-tasks`(get_user_todos_in_current_org 三角色 creator+executor+participant 并集 + taskId 去重 + 投影)、`doc +find-doc`(search_documents 关键词搜文档 + title/url/type/token 投影)。均以 helper 为 ground truth、0 编造。文档全量同步到 563/503/52(report.md/html、lark-alignment.md gap-buildable 49→44、covered-smart→46、comparison.html 重生成)。全量测试 + app 回归全绿。
> 2026-07-09 续跑增量(批9·手工):新建 3 条智能 shortcut——`minutes +detail`(单命令聚合一条听记 basic/summary/keywords/transcript/todos、partial-failure 容错)、`minutes +replace-batch`(多组 `原文=>替换` 批量替换、去重校验+逐组聚合)、`aitable +record-share-links`(>20 条记录分享链接:去重+分片≤20/批+跨 `aitable-helper` server fanout+合并)。均以 helper 为 ground truth。**同步刷新全部文档到 560/501/49**:`shortcut-report.md`、`shortcut-report.html`、`shortcut-lark-alignment.md`(gap-buildable 49→46、covered-smart 41→44)、重生成 `shortcut-comparison.html`。全量测试 + app 回归全绿。
---
## 1. 背景与目标
- 对齐基准:`/Users/dennis/Projects/larksuite/cli`(lark-cli 的 `shortcuts/` 框架,飞书 REST API)。
- dws 执行底座:**钉钉 MCP**(粗粒度:一个 tool = 一个完整操作)。
- 目标:把 lark 的 shortcut 能力**深度对齐每一个**到 dws,并补钉钉侧系统性能力。
- 关键认知:lark 的"组合性"多源于飞书 API 细粒度(先查 token→id→再操作);钉钉 MCP 粗粒度,**lark 的多步在钉钉大量塌缩成 1:1(已被封装层覆盖)**。真正需要"编排"的是「按名解析 ID + 多工具串联 + 跨服务」——这些做成了 smart 层。
---
## 2. 架构与关键文件
```
internal/shortcut/
types.go # Shortcut / Flag / Risk 声明结构
runner.go # RuntimeContext + mount(编译成cobra) + CallMCP/CallMCPData/Output + 校验/dry-run/风险确认
validate.go # 跨字段校验 helper:MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/RequireAll
register.go # Register() / Commands() / All()
shortcut_test.go# 框架单测
builtin/
builtin.go # blank-import 所有服务包 + smart 包;Commands() 汇总
coverage_test.go # ★全量测试:TestAllShortcutsAssemble / TestAllToolLiteralsAreReal / TestAllHaveIntent / TestNoDuplicateCommands
<service>/ # 19 个服务包:contact/chat/calendar/todo/doc/drive/mail/wiki/minutes/oa/report/attendance/aitable/sheet/devapp/ding/aisearch/live/devdoc
<service>.go # 该服务的 1:1 封装 shortcut(var + init(){shortcut.Register(...)})
smart/ # ★真·智能层(多步/编排/按名解析/跨服务)
resolve.go # resolveUser(rt,name) 名→userId+消歧;contactUser{userID,name};extractUsers/userLabels
dm.go lookup.go assign.go book.go free.go ... # 每条一个文件
usage/ # P2 埋点:recorder.go(记形状不记值) stats.go command.go(dws shortcut list/stats/suggest/add)
userdef/ # P2 自定义 shortcut YAML 运行时加载 loader.go
internal/app/legacy.go # 接线点:newLegacyPublicCommands 里 append builtin.Commands() + userdef.Load()
internal/app/root.go # 装配 recordingToolCaller(埋点) + dws shortcut 命令
internal/helpers/*.go # ★Ground truth:钉钉真实 MCP tool 名 + 参数(callMCPTool("tool",{...}))
docs/
shortcut-plan.md # 总规划
shortcut-p2-design.md # P2 自动沉淀设计
shortcut-report.md / .html # 综合报告 + GSB
shortcut-comparison.html # 逐条三方对照(dws vs lark vs 原生MCP)
shortcut-lark-alignment.md # ★深度对齐矩阵(lark 361条逐条分析, 49 gap-buildable)
shortcut-handoff.md # 本文件
scripts/gen_shortcut_comparison.py # 生成三方对照 HTML
```
---
## 3. 框架契约(写新 shortcut 必读)
一个 shortcut = 包级 `var X = shortcut.Shortcut{...}` + `func init(){ shortcut.Register(X) }`。
```go
var SearchUser = shortcut.Shortcut{
Service: "contact", // 顶层命令
Command: "+search-user", // + 前缀,kebab-case
Product: "contact", // MCP server id(默认=Service;注意跨 server,见坑位)
Description: "...", // 一行
Intent: "自然语言:做什么/何时用/副作用", // 每条必填(TestAllHaveIntent 强制)
Risk: shortcut.RiskRead, // Read / Write / HighWrite(删除等,框架二次确认)
Flags: []shortcut.Flag{{Name:"query", Type:shortcut.FlagString, Required:true, Desc:"...", Enum:[]string{...}}},
Validate: func(rt *shortcut.RuntimeContext) error { return rt.RequireAll("query") }, // 可选
Execute: func(rt *shortcut.RuntimeContext) error { ... },
}
```
RuntimeContext 方法(`internal/shortcut/runner.go`/`validate.go`):
- 读参数:`rt.Str/Bool/Int/StrSlice(name)`、`rt.Changed(name)`
- **调 MCP 并打印**(终结步,1:1 封装用):`rt.CallMCP(tool, params) error`(用自身 Product)
- **调 MCP 拿数据**(多步/投影用,不打印,可跨 server):`rt.CallMCPData(product, tool, params) (map[string]any, error)`
- **投影输出**:`rt.Output(payload) error`(吃 --format/--jq/--fields)
- 校验:`rt.MutuallyExclusive/AtLeastOne/ExactlyOne(flags...)`、`rt.RangeInt(flag,min,max)`、`rt.RequireAll(flags...)`
- smart 复用:`resolveUser(rt, name) (contactUser, error)`(名→userId+消歧,在 smart/resolve.go)
对标 lark:`CallMCPData`≈`CallAPITyped`;`resolveUser`≈`ResolveOpenIDsTyped`;`rt.Output`≈`OutFormat`;`Validate helper`≈lark 的 MutuallyExclusive/AtLeastOne。
---
## 4. 方法论(怎么高效批量建,屡试不爽)
**核心:多 agent workflow 并行 + helper 为 ground truth + 严格 skip + build/test 门禁。**
1. **每个 shortcut/服务一个 agent**,并行(`parallel(...)`)。
2. **Ground truth 铁律**:tool 名和参数 key **只能逐字取自 `internal/helpers/<svc>.go` 的真实 `callMCPTool("tool",{params})` 调用点**,严禁编造。agent 必须先 Read+grep helper。
3. **宁缺勿错**:拿不准的 tool/参数/结构 → **skip 并说明**,不瞎写(已多次证明 agent 会正确 skip,如 mail 无 email 字段)。
4. **响应字段防御式解析**:返回结构无契约保证 → 多候选 key 探测(result/data/list/items + 字段别名),不硬编码。
5. **不同 agent 写不同文件**(服务包 vs smart 包,或不同 service 文件)→ 无写冲突;**禁止 agent 改 builtin.go**(我事后统一维护 blank import)。
6. **落地后统一**:`gofmt -w` → `go build ./...` → shortcut 全量测试 → 命名冲突用 rename 修(如 smart 的 `+approve` 撞 1:1 层 → 改 `+approve-by`)。
7. **周期性 app 全量回归**(改多个服务文件后):`go test ./internal/app/...`(~73s)验证接线。
workflow 脚本模板见任意 `~/.claude/.../workflows/scripts/build-smart-*.js` 或 `upgrade-fidelity-*.js`(每次 Workflow 调用都存了盘,可 `{scriptPath}` 复用/改)。
---
## 5. 已完成进展
### 5.1 覆盖层(511 条 1:1 封装 / 19 服务)
chat89 aitable86 mail43 attendance36 doc33 minutes31 devapp30 sheet29 calendar24 drive24 oa20 todo18 wiki15 contact14 report7 ding7 aisearch3 live1 devdoc1。每条带自然语言 Intent。
### 5.2 智能层(~46 条 `internal/shortcut/smart/`)
按名操作人/群、多步编排+失败回滚、时间/自身智能、跨服务、钉钉原生编排。已建(举例):
`chat +dm/+send-to-group/+broadcast/+group-members/+at-me/+search-msg/+unread-chats`、`contact +lookup/+org/+team/+by-mobile/+dept-members`、`calendar +book(回滚)/+free/+today/+week/+next-event/+invite/+suggest-time/+reschedule/+cancel-event/+respond-event/+find-room`、`todo +assign/+assign-multi/+overdue/+todo-done/+remind/+created-todos`、`minutes +latest-minutes/+action-items/+transcript/+minutes-search/+detail/+replace-batch`、`oa +approve-by/+my-initiated`、`attendance +my-attendance`、`report +report-latest`、`aitable +find-record/+list-tables`、`doc +share-doc/+doc-append`、`wiki +wiki-new-doc`、`drive +find-file`、`mail +search-mail/+unread-mail`。
### 5.3 框架系统性能力(对齐 lark)
`resolveUser`、`CallMCPData`、`rt.Output`、`Validate×5`。
### 5.4 保真度升级(~64 条封装:CallMCP→CallMCPData+投影+Output,对齐 lark 96% 输出投影)
覆盖 contact/chat/calendar/todo/doc/drive/mail/wiki/aitable/oa/devapp/attendance/minutes/report/sheet 等服务的列表类命令。
### 5.5 P2 高频自动沉淀(差异化,lark 无)
埋点(记形状不记值,默认关/opt-in DWS_USAGE_TRACKING=1) → `dws shortcut stats/suggest` → `dws shortcut add` 写 `~/.dws/shortcuts/*.yaml` → 运行时 `userdef.Load()` 编译注册。**闭环端到端跑通。**
### 5.6 深度对齐矩阵
`docs/shortcut-lark-alignment.md`:逐条分析 lark 361 条 → covered-1to1 144 / no-dingtalk-tool 127 / **gap-buildable 49** / covered-smart 41。
---
## 6. 如何继续(下一步 backlog)
1. **保真度升级剩余列表命令**(还有部分服务的 list 命令仍是裸 CallMCP):起 `upgrade-fidelity-N` workflow,每服务 agent 挑 1-2 个未升级(`grep 'rt.CallMCP('`)的列表读命令,改成 CallMCPData+投影+Output。范式见 `contact.go` 的 `searchUserProject`/`listRolesProject`。
2. **补剩余 gap-buildable smart shortcut**(矩阵里 49 个,已建 ~20+):起 `build-smart-N` workflow(smart 包,不碰服务包)。剩余偏复杂(sheets/base 操作、消息富化、分片下载),谨慎、允许 skip。
3. **每批**:gofmt→build→shortcut 测试→(改多文件后)app 回归→更新 `docs/shortcut-report.md`。
4. **收尾**:把 `docs/shortcut-report.md/html` 的计数刷新到最终(部分 §2 测试数字可能还停在旧值 511/456,实际 ~557/~500),重跑 `python3 scripts/gen_shortcut_comparison.py`。
---
## 7. 关键坑位(务必注意)
- **跨 server 路由**:有些 tool 不在本服务 server。已知:contact 花名册 tool 走 `hrmregister`;chat 部分 tool 走 `im`/`bot`;`query_check_record` 走 `attendance-wukong`(不是 attendance);wiki `create_file` 走 `doc` server。→ 这类必须用 `rt.CallMCPData("<真实server>", ...)`,不能用 `rt.CallMCP`(它按 shortcut.Product 路由会打错 server)。判断依据:helper 里是 `callMCPToolOnServer("<server>", ...)`。
- **命名冲突**:smart 命令别撞 1:1 层(如 `+approve`→用 `+approve-by`,`+freebusy`→用 `+free`)。`TestNoDuplicateCommands` 会抓。
- **参数别名**:aitable 查询关键词是 `keyword`(不是 query);todo 建待办用嵌套 `PersonalTodoCreateVO`;日程 create/update 时间是 ISO 字符串,而 list/busy 是毫秒——一切以 helper 调用点为准。
- **中文 const tool 名**:minutes 录音 tool 是中文 `"执行听记指令-发起AI听记录音"`(const `listeningNoteCmdTool`)——真实,别当编造。
- **测试真机验证**:token 有时效(`dws auth status` 看 expires),过期会报错非代码问题。真机跑命令时第一次有 catalog 发现 banner(stderr),结果 JSON 在后面,别用 `head` 截断。
- **工具标签格式**(给 AI:本会话我多次误用错标签导致工具调用失败——务必用正确的 function-call 格式)。
---
## 8. 验证命令速查
```bash
cd /Users/dennis/Projects/dingtalk-workspace/dingtalk-workspace-cli
go build ./... # 编译
gofmt -l internal/shortcut/ # 格式(应空)
go test ./internal/shortcut/... # shortcut 全量(含 assemble/tool-real/intent/no-dup)
go test ./internal/app/... # app 回归(~73s,改服务文件后必跑)
go test ./internal/shortcut/builtin/ -run TestAllShortcutsAssemble -v 2>&1 | grep shortcuts= # 看总数
# 真机(需登录 dws auth login)
DWS_USAGE_TRACKING=0 dws contact +lookup --name <真实姓名> # 智能多步示范
DWS_USAGE_TRACKING=0 dws calendar +today # 时间智能
DWS_USAGE_TRACKING=0 dws contact +search-user --query <名> # 投影输出示范
```
测试口径:`TestAllShortcutsAssemble` = 假 Caller 拦截,给每条命令(含写/删)喂合成参数、走完解析→校验→组装 MCP 调用、断言 tool 真实无 panic(零副作用全量验证)。`TestAllToolLiteralsAreReal` = 所有 CallMCP tool 名比对 helper ground truth 防编造。
---
## 9. 未提交提醒
所有工作在 `feature/shortcut`,**未 commit**。建议尽快分语义化 commit 留存(框架 / 511封装 / smart层 / 保真度升级 / P2 / 文档)。
+187
View File
@@ -0,0 +1,187 @@
# lark-cli Shortcut 深度对齐矩阵
> 12 个 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉、标注保真度差距。
## 2026-07-13 最新源码复核
对比基线:
- DWS:`feature/shortcut@b7c14c1`(已合并 `origin/main@390b611`)
- lark-cli:`main@e96c4fa5`
- lark-cli 本轮更新范围:`f495cbb1..e96c4fa5`
本轮 lark-cli **没有增加或删除生产 shortcut 命令**,变化集中在已有命令的实现保真度:统一 `--json` shorthand、文档分享锚点读取、whiteboard 本地文件安全内联、VC meeting events 的 identity/timeline/NDJSON 投影、Apps DB 环境自动选择、Drive push 错误分类,以及 Wiki token 解析兼容性。因此下方历史 gap 清单的命令面没有因本轮 pull 新增条目,但若要追平体验,以下实现差距需要上调优先级。
### 当前命令面快照
| 指标 | 数量 | 说明 |
|---|---:|---|
| DWS built-in shortcut | 366 | 16 个服务;运行时 registry 实测 |
| lark-cli primary shortcut | 363 | 19 个服务;排除 `_test.go` 与 42 个 `sheets/backward` 隐藏兼容别名 |
| 双方可映射服务内命令 | DWS 313 / lark 324 | 12 组产品映射,不含平台特有服务 |
| 同服务同名命令 | 50 | 仅是名称交集,不等于语义等价或保真度一致 |
| DWS 平台特有 shortcut | 53 | attendance / ding / oa / report 等 |
| lark 平台特有 shortcut | 39 | okr / vc / slides / markdown / whiteboard / note / event |
双方重叠服务的命令面如下;“同名”只用于定位,能力判断仍需看参数、验证、多步编排、输出投影和 dry-run:
| 产品映射 | DWS | lark | 同名 |
|---|---:|---:|---:|
| aitable ↔ base | 82 | 87 | 31 |
| calendar ↔ calendar | 23 | 10 | 3 |
| chat ↔ im | 89 | 21 | 2 |
| contact ↔ contact | 16 | 2 | 1 |
| devapp ↔ apps | 30 | 63 | 3 |
| doc ↔ doc | 19 | 14 | 1 |
| drive ↔ drive | 9 | 26 | 3 |
| mail ↔ mail | 10 | 21 | 0 |
| minutes ↔ minutes | 13 | 9 | 1 |
| sheet ↔ sheets | 2 | 42 | 0 |
| todo ↔ task | 13 | 17 | 2 |
| wiki ↔ wiki | 7 | 12 | 3 |
### 最新优先差距
1. **文档与白板资源保真度**:lark `doc +fetch/+update` 已支持分享链接 selection anchor、HTML5 block 资源引用,以及相对路径内的 SVG/Mermaid/PlantUML whiteboard 安全内联。DWS 具备文档读写和媒体原子能力,但缺少统一引用解析、路径门禁和资源回写编排。
2. **Sheets typed workflow**:lark 的 typed table、批量样式、维度移动/冻结、range copy/fill/sort、workbook import/export 仍是最大可建设缺口。DWS 原生 helper 已有部分底层能力,但 shortcut 层只有 2 个精选命令,缺少跨 sheet 分块写、类型推断和 partial rollback。
3. **Drive 本地同步体验**:lark `+push/+pull/+sync/+import/+export` 带批量计划、错误分类、路径保护和版本操作;DWS 目前偏原子上传/搜索,缺完整目录同步和可恢复批处理。
4. **Mail 高保真写链路**:lark 对 send/reply/reply-all/forward 提供模板、签名、HTML lint、线程头、定时和附件编排;DWS 有底层发信/草稿工具,但 smart shortcut 尚未覆盖这些组合体验。
5. **消息资源与统一搜索**:DWS 已有 `+search-msg/+chat-messages/+thread-replies/+at-me` 等拆分场景,lark `+messages-search` 仍在统一多维过滤、会话上下文富化、reaction/资源下载方面更完整。
6. **会议事件输出**:lark `vc +meeting-events` 本轮新增当前身份、actor、会议状态推断、timeline 与 NDJSON 元数据。DWS 最新 main 已有更强的实时 event bus 和个人事件订阅,但尚未沉淀成同等级 shortcut 投影;这是“底层能力领先、shortcut UX 未收口”。
### 不建议机械追平
- lark Apps DB、Spark 发布、Lark Drive/Wiki 特有对象模型属于平台差异,不应只为同名率复制。
- DWS 的 attendance、DING、OA、report、agoal 和最新 event bus 是钉钉侧差异化能力,应优先做场景化组合,而不是追求 363 vs 366 的数字对齐。
- DWS 已具备按姓名解析、跨产品智能编排、失败回滚和 usage→自定义 shortcut 沉淀闭环,这些能力无法由同名命令统计体现。
> 注:下方“361 条”汇总是上一轮逐条人工分类的历史基线;当前 lark-cli primary shortcut 是 363 条,另有 42 个不应重复计为能力的 Sheets 隐藏兼容别名。历史条目的判断仍可复用,但总量数字不能直接代表本轮最新覆盖率,后续应把新增条目按 covered-1to1 / covered-smart / gap-buildable / no-dingtalk-tool 四类补录。
## 汇总(361 条 lark shortcut)
| dws_status | 数量 | 含义 |
|---|:---:|---|
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
| **gap-buildable** | **42** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`、task `+related-tasks` |
| covered-smart | 48 | 已建智能 shortcut / 部分覆盖 |
## 🎯 gap-buildable 目标清单(原 49 条,已建 7 → 剩 42,按服务)
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
### im → chat(6)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+chat-list` | read | dws 有 list-my-groups/list-all-conversations 原子 tool,但无 types 枚举+bot剥p2p降级、无 exclude-muted 客户端过滤、无字段投影 |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
### task → todo(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reminder` | write | dws 有 add_todo_reminder/reset_todo_reminder 但无 lark 的先查现有再替换编排、相对时间(15m/1h)解析与互斥校验,值得补智能 shortcut |
| `+get-related-tasks` ✅ | read | **已建 smart `todo +related-tasks`**:creator+executor+participant 三角色并集 + taskId 去重 + 投影。剩余未做:followed-by-me 成员比对、subtask_count/tasklists 富投影 |
| `+upload-attachment` | write | dws add-attachment 走 init→PUT→commit 三步 MCP 上传(能力更重),但无 50MB/regular 校验、applink 提取与 dry-run 计划展示;可对齐成更智能 shortcut |
### calendar → calendar(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+room-find` | read | dws 有 room search(query_available_meeting_room 按单一时间段+过滤)和 busy search,但无多slot并发room_find聚合、无city/building/floor/capacity维度过滤、无按attendee推荐可用室,值得补成智能 shortcut 但未建 |
### doc (docs) → doc(2)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+media-insert` | write | dws doc media insert 为3步(取凭证→PUT→insert_document_block)无回滚、无selection定位、无剪贴板、无宽高比补算、无wiki解析;可补成带回滚的智能shortcut |
| `+media-download` | read | dws doc media download 走resourceId→downloadUrl两段,缺whiteboard导图分支、自动扩展名、路径安全、overwrite防护;media分支可对齐,whiteboard无工具 |
### drive → drive(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+import` | write | dws drive upload 有 --workspace --convert 可转在线文档,但缺按目标类型(docx/sheet/bitable/slides)导入、缺 target-token 挂载与异步轮询 |
### mail → mail(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+reply` | write | dws reply 走 create_reply_draft+send_draft 两步、附件仅上传会话,缺 EML 线程头构造、签名自动注入、模板合并、HTML lint、读回执、send-time 定时、跨字段校验 |
| `+reply-all` | write | dws reply-all 两步且收件人由服务端决定,缺原文收件人抽取去重排己、线程头、签名/模板/lint/定时等编排保真 |
| `+send` | write | dws send_email 单步(附件时先 create_draft 再传再 send),缺签名/模板/lint/日历内嵌/定时发送/发件人profile解析/跨字段校验 |
| `+forward` | write | dws forward 走 create_forward_draft+send_draft,缺 Fw:主题/引用块/原附件转载 EML 构建、签名/模板/lint/定时保真 |
### wiki → wiki(1)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+node-get` | read | dws 无 get_node 对应 tool(proxy wiki doc read 读的是文档正文而非节点元数据/space解析);缺 token/obj_token/URL→node 解析、obj_type推断、space交叉校验——是值得补的智能 shortcut 缺口 |
### minutes → minutes(4)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+search` | read | dws list_by_keyword_and_time_range 只按 keyword+时间+归属(created/shared)过滤,缺 owner/participant 的 me 解析与筛选、缺 query 长度与跨字段互斥校验、缺输出投影与去头像 |
| `+download` | read | dws 只有 query_minutes_audio_url 返回 OSS 地址(相当于 --url-only 单条),缺真正落盘下载、批量 fanout+限速+去重、文件名推断、SSRF 防护与覆盖保护 |
| `+word-replace` ✅ | write | **已建 smart `+replace-batch`**:多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合(补齐 1:1 `+word-replace` 的单组限制)。剩余未做:@file/stdin 输入 |
| `+detail` ✅ | read | **已建 smart `+detail`**:单命令按 `--artifacts` fanout basic/summary/keywords/transcript/todos + partial-failure 容错 + rt.Output 投影。剩余未做:wait-ready 轮询、transcript 落盘 |
### base → aitable(10)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+title-resolve` ✅ | read | **已建 smart `aitable +resolve-base`**:search_bases 按名解析 baseId + 0/1/多候选消歧投影。剩余未做:Drive doc_wiki 全文搜索 |
| `+field-create` | write | dws create_fields 支持批量,但缺 formula/lookup guide-ack 门禁与逐字段节流,可补智能 shortcut |
| `+field-update` | write | dws update_field 缺 formula/lookup guide-ack 保护 |
| `+record-share-link-create` ✅ | read | **已建 smart `+record-share-links`**:>20 条记录去重 + 分片(≤20/批) + 跨 aitable-helper server fanout + 合并 {recordId,shareUrl},补齐单批 20 条上限 |
| `+record-upload-attachment` | write | dws 只有 prepare_attachment_upload(拿上传凭证),缺 分片上传编排+append_attachments 回填单元格的完整链路 |
| `+dashboard-block-list` | read | dws 仪表盘块是 chart(create/get/update/delete_chart),缺通用 block list,可对齐补 |
| `+dashboard-block-get` | read | dws get_chart 覆盖 chart 类块,缺通用 block get |
| `+dashboard-block-create` | write | dws create_chart 覆盖图表块,缺其他 block 类型的通用创建 |
| `+dashboard-block-update` | write | dws update_chart 覆盖图表块更新 |
| `+dashboard-block-delete` | high-risk-write | dws delete_chart 覆盖图表块删除 |
### sheets → sheet(14)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+sheet-hide` | write | dws update_sheet可能含hidden属性但未见独立hide命令,需确认 |
| `+sheet-unhide` | write | 同上,dws无独立unhide命令 |
| `+sheet-set-tab-color` | write | dws update_sheet或可设tab色但无独立命令 |
| `+sheet-show-gridline` | write | dws无网格线显隐命令 |
| `+sheet-hide-gridline` | write | dws无网格线显隐命令 |
| `+workbook-create` | write | dws有create_workspace_sheet但仅建空表,缺typed一步建表+填充+样式+partial回滚编排 |
| `+dim-hide` | write | dws update-dimension或含hidden但无独立hide命令 |
| `+dim-unhide` | write | 同上,dws无独立unhide命令 |
| `+dim-freeze` | write | dws update-dimension可能含frozen但无独立freeze命令 |
| `+cells-get` | read | dws range read存在但缺include样式/公式投影统一封装 |
| `+table-get` | read | dws缺typed table读回+列类型推断+多sheet编排,只有裸csv/range读 |
| `+table-put` | write | dws有append/set_cell_range但缺typed多sheet分块写+建缺失sheet+样式+partial回滚编排 |
| `+rows-resize` | write | dws update-dimension可调尺寸但无独立rows-resize+size/type互斥校验 |
| `+cols-resize` | write | dws update-dimension可调尺寸但无独立cols-resize+互斥校验 |
### apps → devapp(3)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+release-create` | write | dws 有 create_dev_app_version(开放平台版本)可类比,但妙搭 release 是低代码应用发布、语义与产物不同 |
| `+release-get` | read | dws 有 get_dev_app_version_detail 可类比但产品域(开放平台vs妙搭)不同 |
| `+release-list` | read | dws 有 list_dev_app_versions 可类比但无 status 枚举过滤且产品域不同 |
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
- **im**: +chat-members-list +messages-send +threads-messages-list
- **task**: +complete +assign +get-my-tasks +get-related-tasks
- **contact**: +search-user
- **calendar**: +agenda +create +update +freebusy +suggestion
- **doc (docs)**: +history-revert
- **drive**: +upload +search +inspect
- **mail**: +triage
- **minutes**: +upload +latest-minutes +action-items +transcript +minutes-search +detail +replace-batch
- **base**: +table-get +table-create +view-create +view-get-filter +view-set-filter +view-get-visible-fields +view-set-visible-fields +view-get-group +view-set-group +view-get-sort +view-set-sort +view-get-timebar +view-set-timebar +view-get-card +view-set-card +record-list +record-search +record-get +record-upsert +base-create +workflow-list +form-create +form-list +form-get +record-share-link-create
+204
View File
@@ -0,0 +1,204 @@
# DWS Shortcut P2 详细设计 — 高频场景自动沉淀为自定义 Shortcut
> 前置:P1 已交付静态声明式 shortcut 框架(`internal/shortcut/`),见 `docs/shortcut-plan.md`。
> P2 目标:**观察用户高频使用 → 主动建议 → 一键沉淀为可复用的自定义 shortcut**,
> 让 CLI 越用越顺手。这是 dws 相对 larksuite/cli 的差异化能力。
## 0. 体验闭环(一句话)
```
用户反复敲 dws chat send_message --json '{"open_conversation_id":"cid_x","text":"..."}'
│ (每次执行被静默记录到 ~/.dws/usage.jsonl)
▼
第 N 次后,dws 主动提示:
💡 你已 12 次向「项目群」发消息,是否沉淀为 `dws chat +notify-team`?[y/N]
│ y
▼
写入 ~/.dws/shortcuts/chat.notify-team.yaml
▼
之后:dws chat +notify-team --text "发布完成" ← 参数从一堆 JSON 收敛成一个 flag
```
## 1. 总体架构
复用 P1 的 `Shortcut` 模型作为「编译目标」,新增四个部件:
| 部件 | 位置(建议) | 职责 |
|------|-------------|------|
| Usage 埋点 | `internal/shortcut/usage/recorder.go` | 每次 MCP 调用后追加一条 usage 记录 |
| 模式挖掘 | `internal/shortcut/usage/miner.go` | 聚合 usage → 高频候选 + 打分 |
| 主动提示 | `internal/shortcut/usage/nudge.go` | 命中候选时在命令收尾处提示 |
| YAML 加载 | `internal/shortcut/userdef/loader.go` | 扫描 `~/.dws/shortcuts/*.yaml` → 编译成 `Shortcut` → 注册 |
| 管理命令 | `internal/shortcut/usage`(cobra) | `dws shortcut list/suggest/add/rm/stats` |
数据流:
```
CallMCP ──► recorder.Append(usage) [写侧,热路径,必须极轻]
┌─► miner.TopCandidates() [读侧,suggest 时才算]
~/.dws/usage.jsonl ─────────────────┤
└─► nudge (命令收尾抽样触发)
~/.dws/shortcuts/*.yaml ──► loader.Compile() ──► shortcut.Register() [启动时]
```
## 2. Usage 埋点
### 2.1 采集点(choke point)
**首选**:装饰 `executor.Runner` / `edition.ToolCaller`。`internal/app/tool_caller_adapter.go`
的 `CallTool(ctx, productID, toolName, args)` 是**所有 MCP 调用的唯一必经点**,天然拿到
`(product, tool, args)` 三元组。用装饰器包一层即可,零侵入命令层:
```go
type recordingCaller struct{ inner edition.ToolCaller }
func (r recordingCaller) CallTool(ctx, product, tool string, args map[string]any) (*edition.ToolResult, error) {
res, err := r.inner.CallTool(ctx, product, tool, args)
usage.Append(product, tool, args, err == nil) // 异步/带 recover,绝不影响主流程
return res, err
}
```
> 注意:P1 的 shortcut 也走这条 `CallMCP → helpers → deps.Caller`,所以内建 shortcut 的
> 使用同样会被记录,可用于「哪些内建 shortcut 最受欢迎」的洞察。
### 2.2 记录内容(**隐私优先:记形状不记值**)
`~/.dws/usage.jsonl`,每行一条:
```json
{
"ts": "2026-07-08T10:12:33+08:00",
"product": "chat",
"tool": "send_message",
"arg_keys": ["open_conversation_id", "text"],
"const_args": {"open_conversation_id": "cid_x"},
"ok": true
}
```
- `arg_keys`:参数键集合(排序),用于识别「同一种调用形状」。
- `const_args`:**仅收敛出的「疑似固定值」**(见 §3 挖掘时判定),写入时不保证脱敏,
因此需要一层白名单/黑名单:`text/content/body/message` 等自由文本字段**永不入库**,
只保留看起来像 ID/枚举的短值(长度阈值 + 无空格 + 非多行)。
- 绝不记录:token、手机号、邮箱、文件内容、消息正文。用 `internal/logging/redact.go`
已有的脱敏能力复核。
### 2.3 热路径约束
- 追加写用 `O_APPEND`,单行 < 1KB;失败静默(`recover` + debug 日志),**绝不阻断命令**。
- 文件滚动:超过 N 行(如 5000)或 M 天,截断/归档,避免无限增长。
- 开关:默认关闭(opt-in),环境变量 `DWS_USAGE_TRACKING=1` 开启(本地遥测即便只记形状也不应未经用户同意默认开启)。
首次启用时在 `dws` 首跑给一次性告知(尊重知情)。
## 3. 模式挖掘
`dws shortcut suggest` 触发(也被 nudge 复用)。算法:
1. 读 usage.jsonl,按 `(product, tool, arg_keys)` 分桶。
2. 对每桶:
- `count` = 出现次数;低于阈值(默认 5)直接丢弃。
- 对每个 arg_key,统计其值的分布:某值占比 ≥ 80% → 判定为**固定值**(进 `const_args`);
否则判定为**可变参数**(沉淀后成为 flag)。
- `recency` = 最近一次使用距今;越近权重越高。
3. 打分 `score = count * log(distinct_days+1) * recencyDecay`,取 TopN。
4. 生成候选 `Candidate{product, tool, fixed{...}, varFlags[...], score, samples}`。
输出示例(`dws shortcut suggest --format table`):
```
候选 | 命令建议 | 依据 | 固定参数 | 可变flag
#1 | chat +notify-team | 12 次 / 近 3 天 | open_conv=cid_x | text
#2 | doc +new-agenda | 7 次 / 近 5 天 | template=agenda | title
```
## 4. 主动提示(nudge)
- **时机**:命令成功收尾时(root `PersistentPostRunE`),**抽样**触发(如每 N 次调用或每次
命中新达标候选时),避免打扰。仅在 TTY 交互态提示;非交互(Agent/管道/`--yes`)**不提示**。
- **频控**:同一候选提示过一次被拒后,冷却期内不再提示(记 `~/.dws/shortcuts/.declined`)。
- **交互**:
```
💡 检测到高频操作:你已 12 次向同一会话发消息。
沉淀为快捷指令 dws chat +notify-team --text "..." ?
[y] 沉淀 [n] 以后再说 [d] 不再提示此项
```
- y → 走 §5 生成 YAML;命名默认 `+<tool 去下划线的动宾>`,允许用户改名。
## 5. 自定义 Shortcut:YAML 格式与运行时加载
### 5.1 YAML schema(与 P1 `Shortcut` 一一对应)
`~/.dws/shortcuts/chat.notify-team.yaml`:
```yaml
version: 1
service: chat
command: "+notify-team"
product: chat
description: "发消息到 项目群(自动沉淀于 2026-07-08)"
risk: write # 默认 read;send 类判定为 write
source: auto # auto=沉淀 / manual=手写
flags:
- name: text
type: string
required: true
desc: 消息内容
execute:
tool: send_message
bind: # 参数绑定:常量 + ${flag} 模板
open_conversation_id: "cid_x"
text: "${text}"
```
### 5.2 编译与注册
`userdef.Compile(yaml)` → `shortcut.Shortcut`,其 `Execute` 由 `bind` 生成:
遍历 `bind`,`${flag}` 用 `rt.Str(flag)` 填充,常量原样,组装 params 后 `rt.CallMCP(tool, params)`。
完全复用 P1 的 runner,不新增执行路径。
加载时机:`legacy.go` 装配点,在 `builtin.Commands()` 之后追加 `userdef.Commands()`,
一起 merge。复用 `internal/plugin/loader.go` 已验证的「扫 `~/.dws/` 目录 + 挂 cobra」模式。
### 5.3 冲突与优先级
- 自定义 shortcut 命令名若与内建 shortcut / helper 冲突:**内建优先**,自定义重命名或跳过并告警。
- `+` 前缀天然与 helper leaf 区分,冲突面小。
## 6. 管理命令面
```
dws shortcut list # 列出内建 + 自定义 shortcut
dws shortcut suggest [--min N] # 展示高频候选(不写入)
dws shortcut add <candidate|--from-last> # 交互式/从最近一次调用沉淀
dws shortcut rm <service> <+cmd> # 删除自定义 shortcut
dws shortcut stats # usage 统计概览
```
`dws shortcut` 本身作为一个新的顶层 utility 命令注册(对齐 `dws plugin`)。
## 7. 安全与隐私边界(红线)
1. **值不入库**:自由文本/正文/凭证一律不记;`const_args` 仅短 ID/枚举,且过 redact 复核。
2. **可关可清**:`DWS_USAGE_TRACKING=0` 关闭;`dws shortcut stats --purge` 清空 usage。
3. **执行白名单**:自定义 shortcut 的 `execute.tool` 必须解析到合法 MCP server(过
`internal/security` endpoint 白名单),禁止指向任意 endpoint。
4. **不自动执行**:沉淀只生成命令定义,**绝不**自动发起写操作;写类 shortcut 仍受 P1 的
risk 确认约束。
5. **知情**:首次开启埋点一次性告知;提示可永久关闭。
## 8. 实现顺序(P2 分步,便于 loop 推进)
- P2-1 usage 埋点:`recordingCaller` 装饰器 + `usage.Append` + jsonl 写 + 开关 + 脱敏白名单。
- P2-2 `dws shortcut stats` / `list`:先让数据可见,验证埋点质量。
- P2-3 miner + `dws shortcut suggest`:离线挖掘与打分。
- P2-4 userdef YAML 加载 + `Compile` + 注册 + 冲突处理(打通「手写 YAML 也能用」)。
- P2-5 `dws shortcut add`(从候选/最近调用沉淀)。
- P2-6 nudge 主动提示(最后做,最谨慎,默认保守频控)。
## 9. 待决策点(需产品确认)
1. 埋点默认开还是默认关?→ **修订后:默认关(opt-in)+ 开启后首跑一次性告知**(原设计默认开,反思后改为 opt-in:自主 agent 不应单方面默认开本地遥测)
(`DWS_USAGE_TRACKING=0` / 配置项关闭;`dws shortcut stats --purge` 清空)。实现时以此为准。
2. `const_args` 允许记录的字段白名单粒度?(保守起步:只记形如 `*_id/*Id/type/status` 的短值)
3. nudge 触发频率与渠道?(建议:仅 TTY、命中新候选时、每候选一生仅一次)
4. 自定义 shortcut 是否需要跨设备同步?(v1 先本地 `~/.dws/`,同步留待后续)
+127
View File
@@ -0,0 +1,127 @@
# DWS Shortcut 能力 — 总体规划
> 目标:为 dws 引入一套 **声明式高保真命令(Shortcut)** 能力,对齐 larksuite/cli 的 `+command`
> 体验(如 `lark-cli contact +search-user`),并在此之上做 dws 差异化:**基于用户高频使用场景,
> 主动把常用操作沉淀为自定义 shortcut**。
## 1. 背景与动机
dws 当前的命令有三类来源:
1. **MCP 运行时动态发现** —— `dws mcp <service> <tool> --json '{...}'`,通用但裸、参数需手拼 JSON。
2. **`internal/helpers/` 产品命令** —— 手写 cobra 命令,体验好但每个都从零写、缺统一框架。
3. **`internal/registry/recipes.yaml`** —— 多步工作流的静态描述。
痛点:想新增一个「精选、参数友好、带 dry-run/format/身份」的单命令,只能手写 helper,
没有统一的声明式框架,重复劳动多、一致性差。larksuite 的 shortcut 框架正好解决这一层。
## 2. 与 larksuite/cli 的架构差异(关键)
| 维度 | larksuite/cli | dws-cli |
|------|---------------|---------|
| 命令来源 | 静态硬编码 Go shortcut | MCP 运行时动态发现 + helpers |
| 调用底座 | Lark SDK 直连 API | MCP JSON-RPC(`executor.Runner`) |
| 精选命令层 | `shortcuts/`(200+ 声明式) | `internal/helpers/`(手写 cobra) |
| 全局 flag | 框架注入 | root 已内建 `--format/--dry-run/--jq/--yes/--fields/--profile` |
**结论**:不能直接搬代码。移植的是 shortcut 的**声明式设计**,执行底座换成 dws 的
`executor.Runner`,全局能力复用 dws 已有的 output/safety/auth。
## 3. 分期目标
### P1 — 静态声明式框架(本期,正在做)
- 新建独立模块 `internal/shortcut/`(零侵入现有 helpers)。
- `types.go`:`Shortcut` / `Flag` / `RuntimeContext` 声明层。
- `runner.go`:把 `Shortcut` 编译成 `*cobra.Command`,串起 flag 注册 → 校验 → dry-run →
`executor.Runner.Run` → `output.WriteCommandPayload`。
- `register.go`:按 service 分组产出命令,在 `internal/app/legacy.go` 装配点 merge 进命令树。
- 样板命令 `contact +search-user`:打通 MCP 执行 / format / dry-run / 身份,作为后续命令模板。
- 交付判据:`dws contact +search-user --help`、`--dry-run` 正常;`go build` / `go test` 通过。
### P2 — 高频场景自动沉淀(后续,先设计再实现)
- **使用埋点**:命令执行入口记录 `~/.dws/usage.jsonl`(只记参数形状,不记敏感值)。
- **模式挖掘**:`dws shortcut suggest` 聚合高频 `(service, tool, 固定参数组合)`。
- **主动沉淀**:命中候选时提示用户,一键写入 `~/.dws/shortcuts/*.yaml`(声明式,与 P1 结构对应)。
- **运行时加载**:`register.go` 额外扫描 `~/.dws/shortcuts/*.yaml` 动态注册,复用
`internal/plugin/loader.go` 已验证的「从 `~/.dws/` 加载并挂 cobra 命令」模式。
## 4. 落地方式(P1)
采用**独立模块 + 装配点 merge**,不改 helpers 内部:
```
internal/shortcut/
types.go # Shortcut / Flag / RuntimeContext
runner.go # 声明式→cobra 编译 + 执行管道
register.go # Commands(runner) []*cobra.Command,按 service 分组
contact/
search_user.go # 样板:var SearchUser = shortcut.Shortcut{...}
shortcuts.go # Shortcuts() []shortcut.Shortcut
```
接线:`internal/app/legacy.go: newLegacyPublicCommands` 里,
`helpers.NewPublicCommands(runner)` 之后追加 `shortcut.Commands(runner)`,
一起走 `mergeTopLevelCommands`(同名 service 命令自动合并,`+xxx` 作为其子命令)。
复用点:
- 执行:`executor.NewHelperInvocation` + `runner.Run`(与 helper 完全一致的调用路径)。
- 输出:`output.WriteCommandPayload(cmd, resp, output.FormatJSON)`(自动吃 root 的 `--format/--jq/--fields`)。
- dry-run:读 root `--dry-run`,置 `Invocation.DryRun`,由 runner 返回请求预览。
- 身份/安全:复用 `--profile`、`internal/safety`(高风险 `--yes` 确认)。
## 5. Shortcut 声明模型(草案)
```go
type Shortcut struct {
Service string // "contact" → 顶层命令
Command string // "+search-user" → 子命令(保留 + 前缀,对齐 larksuite)
Description string
Risk string // read | write | high-risk-write
Flags []Flag
Validate func(*RuntimeContext) error
Execute func(*RuntimeContext) error // 必填;内部调 rt.CallMCP(...)
}
type Flag struct {
Name, Type, Default, Desc string
Required bool
Enum []string
}
```
`RuntimeContext` 给 Execute 提供:flag 读取(`Str/Bool/Int/StrSlice/Changed`)、
`CallMCP(product, tool, params)`(内部 `runner.Run`)、`Output(payload)`、`DryRun()`。
## 6. 风险与边界
- **与 `dws mcp` 通道的边界**:shortcut 是「人工精选的薄封装」,不替代通用 MCP 通道;
一个 tool 可以既能 `dws mcp` 直调,也能有 shortcut。
- **自定义 shortcut 安全(P2)**:YAML 的 `execute` 若允许任意 MCP 调用,需过
`internal/security` 的 endpoint 白名单,且沉淀的参数值要脱敏。
- **命名冲突**:`+` 前缀天然与现有 leaf 命令区分,降低与 helper 命令的冲突面。
- **edition 差异**:oss / enterprise 的可用 service 不同,注册时按 edition 过滤(后续接入)。
## 7. 进度看板
- [x] P1-1 types.go — `Shortcut` / `Flag` / `Risk` 声明层
- [x] P1-2 runner.go — `RuntimeContext` + `mount` 编译 + 校验/确认/dry-run;`CallMCP` 委托 `helpers.CallMCPToolOnServer`(复用错误分类/输出/dry-run)
- [x] P1-3 register.go + `internal/shortcut/contact/search_user.go` + `builtin` 聚合包
- [x] P1-4 接线 `legacy.go`(append 到 `mergeTopLevelCommands`)+ build/test 全绿
- [ ] P2 设计文档(进行中)
### P1 落地实证(已验证)
- `dws contact +search-user --help`:命令挂载,继承全局 `--format/--dry-run/--jq/...`。
- 必填校验:不传 `--query` → 结构化 validation 错误。
- `--dry-run`:走 helpers 路径输出 `[DRY-RUN]` 预览(tool + 参数)。
- 命令树 merge:`+search-user` 与现有 `user/dept/label/relation` 共存,`contact user search` 未受影响。
- 测试:`internal/shortcut` 单测通过;`internal/app` 全量回归通过。
### P1 关键决策记录
- **执行底座复用 helpers 而非裸 `runner.Run`**:`CallMCP` 委托 `helpers.CallMCPToolOnServer(product, tool, params)`,
一步获得错误分类(auth/PAT/业务)+ 格式化输出 + dry-run,避免重造劣质输出层。代价是
`internal/shortcut → internal/helpers` 的单向依赖(无环)。后续若要 shortcut 做多调用编排/输出重塑,
再补一个返回原始 payload 的 `CallMCPRaw`。
- **避免 import 环**:service 包(contact)import 核心 `shortcut` 包并在 `init()` 注册;
`builtin` 聚合包 blank-import 各 service 包;`app` 只依赖 `builtin`。
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+168
View File
@@ -0,0 +1,168 @@
# Shortcut 真实测试跟进清单
生成时间:`2026-07-15T16:58:39`
来源:`docs/shortcut-real-read-results.json` 与 `docs/shortcut-real-write-results.json`。
口径:记录真实后端测试中需要继续定位的 case,用于 CR 和问题分派;Agent 使用入口以公开 shortcut catalog 和产品 skill 为准。
总计:156 条。
| # | suite | shortcut | risk | status | category | fixability | 处理依据 |
|---:|---|---|---|---|---|---|---|
| 1 | read | `aitable +base-get-primary-doc-id` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 2 | read | `aitable +chart-share-get` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 3 | read | `aitable +dashboard-share-get` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 4 | read | `aitable +export-data` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 5 | read | `aitable +record-primary-doc-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 6 | read | `aitable +role-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 7 | read | `aitable +workflow-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 8 | read | `aitable +workflow-list` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 9 | read | `attendance +get-class` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 10 | read | `attendance +get-global-setting` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 11 | read | `attendance +get-group` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 12 | read | `attendance +get-group-filtered` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 13 | read | `attendance +get-leave-balance` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 14 | read | `attendance +list-report-columns` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 15 | read | `attendance +query-report-leave` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 16 | read | `calendar +find-room` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 17 | read | `calendar +room-find` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 18 | read | `chat +category-list-conversations` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 19 | read | `chat +chat-get-by-id` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 20 | read | `chat +chat-members-get` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 21 | read | `chat +chat-messages` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 22 | read | `chat +messages-list` | read | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 23 | read | `chat +messages-resource-url` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 24 | read | `chat +search-msg` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 25 | read | `chat +thread-replies` | read | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 26 | read | `contact +get-roster` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 27 | read | `contact +list-roster-fields` | read | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 28 | read | `devapp +credentials-get` | read | held | held | manual-approval | 高风险或无安全目标,需人工逐项授权后执行。 |
| 29 | read | `drive +download` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 30 | read | `drive +list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 31 | read | `minutes +action-items` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
| 32 | read | `minutes +latest-minutes` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
| 33 | read | `minutes +minutes-search` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 34 | read | `minutes +transcript` | read | real-error | missing-real-minutes-fixture | not-cli-fixable-without-fixture | 当前账号没有满足条件的妙记/听记或录制会话;需准备真实会议产物后复测。 |
| 35 | read | `oa +done-approvals` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 36 | read | `oa +pending` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 37 | read | `report +report-latest` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 38 | read | `todo +due-today` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 39 | read | `todo +related-tasks` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 40 | read | `wiki +node-list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 41 | read | `wiki +resolve-space` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 42 | read | `wiki +space-list` | read | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 43 | write | `aitable +advperm-disable` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 44 | write | `aitable +advperm-enable` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 45 | write | `aitable +attachment-upload` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 46 | write | `aitable +base-copy` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 47 | write | `aitable +base-delete` | high-risk-write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 48 | write | `aitable +base-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 49 | write | `aitable +chart-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 50 | write | `aitable +chart-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 51 | write | `aitable +chart-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 52 | write | `aitable +dashboard-arrange` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 53 | write | `aitable +dashboard-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 54 | write | `aitable +dashboard-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 55 | write | `aitable +dashboard-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 56 | write | `aitable +field-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 57 | write | `aitable +field-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 58 | write | `aitable +form-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 59 | write | `aitable +form-field-hide` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 60 | write | `aitable +form-field-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 61 | write | `aitable +form-share-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 62 | write | `aitable +form-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 63 | write | `aitable +import-data` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 64 | write | `aitable +import-upload` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 65 | write | `aitable +record-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 66 | write | `aitable +record-primary-doc-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 67 | write | `aitable +record-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 68 | write | `aitable +record-upsert` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 69 | write | `aitable +role-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 70 | write | `aitable +role-delete` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 71 | write | `aitable +role-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 72 | write | `aitable +section-create` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 73 | write | `aitable +section-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 74 | write | `aitable +section-move-node` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 75 | write | `aitable +section-rename` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 76 | write | `aitable +section-reorder` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 77 | write | `aitable +table-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 78 | write | `aitable +table-update` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 79 | write | `aitable +view-delete` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 80 | write | `aitable +view-duplicate` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 81 | write | `aitable +view-lock` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 82 | write | `aitable +view-set-fill-color-rule` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | 后端/MCP 服务返回内部错误;CLI 无法直接修复,但报告保留 trace/stdout 供服务端排查。 |
| 83 | write | `aitable +view-set-frozen-cols` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 84 | write | `aitable +view-set-row-height` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 85 | write | `aitable +view-update` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 86 | write | `aitable +workflow-disable` | high-risk-write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 87 | write | `aitable +workflow-enable` | write | real-error | missing-real-aitable-fixture | not-cli-fixable-without-fixture | AI 表格命令需要真实 Base/Table/View/Record 等资源;安全负向 ID 只能验证调用链,不能让后端成功。 |
| 88 | write | `attendance +boss-check` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 89 | write | `attendance +create-class` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 90 | write | `attendance +create-group` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 91 | write | `attendance +import-schedule` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 92 | write | `attendance +save-leave-balance` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 93 | write | `attendance +update-class` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 94 | write | `attendance +update-group` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 95 | write | `attendance +update-group-members` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 96 | write | `attendance +update-leave-type` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 97 | write | `calendar +respond-event` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 98 | write | `chat +category-add-conversation` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 99 | write | `chat +category-remove-conversation` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 100 | write | `chat +chat-add-bot` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 101 | write | `chat +chat-audit-join` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | dry-run 已证明 CLI 装配了 applicantUid/inviterUid;真实后端仍报 applicantUid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 102 | write | `chat +chat-mute-member` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 103 | write | `chat +chat-quit` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 104 | write | `chat +chat-remove-bot` | high-risk-write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 105 | write | `chat +chat-role-remove` | high-risk-write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 106 | write | `chat +chat-role-remove-user` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 107 | write | `chat +chat-transfer-owner` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 108 | write | `chat +chat-update-icon` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 109 | write | `chat +chat-update-settings` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 110 | write | `chat +conversation-clear-messages` | high-risk-write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 111 | write | `chat +conversation-clear-red-point` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 112 | write | `chat +conversation-hide` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 113 | write | `chat +conversation-mark-read` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 114 | write | `chat +conversation-mark-unread` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 115 | write | `chat +conversation-mute` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 116 | write | `chat +conversation-mute-at-all` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 117 | write | `chat +conversation-mute-red-envelope` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 118 | write | `chat +conversation-set-top` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 119 | write | `chat +messages-add-emoji` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 120 | write | `chat +messages-add-text-emotion` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 121 | write | `chat +messages-batch-recall-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 122 | write | `chat +messages-batch-send-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 123 | write | `chat +messages-combine-forward` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 124 | write | `chat +messages-create-text-emotion` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 125 | write | `chat +messages-forward` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 126 | write | `chat +messages-forward-topic` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 127 | write | `chat +messages-recall` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 128 | write | `chat +messages-recall-by-bot` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 129 | write | `chat +messages-remove-emoji` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 130 | write | `chat +messages-remove-text-emotion` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 131 | write | `chat +messages-send-by-bot` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 132 | write | `chat +messages-send-card` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | dry-run 已证明 CLI 装配了 receiverUid;真实后端仍报 receiverUid/openConversationId 为空,优先按 MCP schema/服务端字段映射问题处理。 |
| 133 | write | `chat +messages-set-pin` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 134 | write | `chat +messages-set-top` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 135 | write | `chat +messages-unset-pin` | write | real-error | backend-or-mcp-error | not-cli-fixable-first | fake MCP 已证明 CLI 已装配会话 ID 字段;真实后端仍报 openConversationId/openCid/cid 缺失,优先按 MCP schema/服务端字段映射问题处理。 |
| 136 | write | `chat +messages-unset-top` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 137 | write | `devapp +event-subscribe` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 138 | write | `devapp +event-unsubscribe` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 139 | write | `devapp +permission-add` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 140 | write | `devapp +permission-remove` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 141 | write | `devapp +robot-config` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 142 | write | `devapp +robot-disable` | high-risk-write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 143 | write | `devapp +robot-enable` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 144 | write | `devapp +security-config` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 145 | write | `devapp +version-create` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 146 | write | `devapp +version-publish` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 147 | write | `ding +send-by-message` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 148 | write | `doc +comment-create-inline` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 149 | write | `doc +template-apply` | write | real-error | auth-or-permission | not-cli-fixable | 真实账号、应用 scope 或资源权限不足;CLI 只能如实暴露,不能在本仓库内修复权限。 |
| 150 | write | `minutes +record-pause` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 151 | write | `minutes +record-resume` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 152 | write | `minutes +record-stop` | write | real-error | input-or-business-validation | test-input-or-backend-rule | 命令已真实进入本地/后端校验;若该项仍使用安全负向输入,则失败符合预期;若使用真实 fixture 仍失败,再作为 CLI bug 处理。 |
| 153 | write | `oa +approve-by` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 154 | write | `wiki +node-copy` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 155 | write | `wiki +node-move` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
| 156 | write | `wiki +wiki-new-doc` | write | real-error | missing-real-resource | not-cli-fixable-without-fixture | 真实测试使用的资源/单据/消息/群/文档不存在;需要准备对应 fixture 后才能期望成功,不属于 shortcut 参数投影错误。 |
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DWS Shortcut 完成情况报告</title>
<style>
:root{--bg:#0f1420;--card:#161d2c;--ink:#e6edf6;--muted:#93a1b5;--line:#26314a;
--blue:#4f9cff;--green:#3fb950;--yellow:#d5a429;--red:#f25c5c;--accent:#6ea8fe;--purple:#a371f7}
*{box-sizing:border-box}
body{margin:0;background:linear-gradient(180deg,#0d1220,#0f1420);color:var(--ink);
font:15px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;padding:0 0 80px}
.wrap{max-width:1080px;margin:0 auto;padding:0 22px}
header{padding:52px 22px 28px;text-align:center;border-bottom:1px solid var(--line);
background:radial-gradient(1200px 300px at 50% -60px,rgba(79,156,255,.16),transparent)}
h1{font-size:29px;margin:0 0 8px;letter-spacing:.5px}
.sub{color:var(--muted);font-size:14px}
.stats{display:flex;gap:13px;justify-content:center;flex-wrap:wrap;margin:26px 0 4px}
.stat{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:15px 20px;min-width:118px}
.stat .n{font-size:27px;font-weight:700;color:var(--accent)}
.stat .l{color:var(--muted);font-size:12.5px;margin-top:2px}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--line)}
h2 .ico{color:var(--accent);margin-right:8px}
h3{font-size:15.5px;margin:22px 0 9px;color:var(--accent)}
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13.5px;background:var(--card);
border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{padding:9px 12px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
th{background:#1b2536;color:var(--muted);font-weight:600;font-size:12.5px}
tr:last-child td{border-bottom:none}
td.c,th.c{text-align:center}
.num{color:var(--accent);font-weight:700;text-align:center}
.g{color:var(--green);font-weight:700}.s{color:var(--yellow);font-weight:700}.b{color:var(--red);font-weight:700}
.ok{color:var(--green)}.star{color:var(--yellow)}
code{background:#0c1120;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:12.5px;color:#cfe0ff}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:15px 18px;margin:13px 0}
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.layer{border-radius:12px;padding:16px 18px}
.l-wrap{background:linear-gradient(180deg,rgba(79,156,255,.08),transparent);border:1px solid #234b6b}
.l-smart{background:linear-gradient(180deg,rgba(163,113,247,.10),transparent);border:1px solid #4a3a6b}
.layer h3{margin-top:0}
.pill{display:inline-block;background:#12283a;color:#7fc6ff;border:1px solid #234b6b;border-radius:6px;padding:1px 7px;font-size:12px;margin:2px 3px 2px 0}
.flow{display:flex;align-items:center;gap:7px;flex-wrap:wrap;font-size:13px;color:var(--muted)}
.flow b{color:var(--ink)}.flow .arw{color:var(--purple)}
.concl{background:linear-gradient(90deg,rgba(63,185,80,.10),transparent);border-left:3px solid var(--green);padding:14px 18px;border-radius:8px;margin-top:16px}
.keyfind{background:linear-gradient(90deg,rgba(213,164,41,.10),transparent);border-left:3px solid var(--yellow);padding:14px 18px;border-radius:8px;margin:14px 0}
footer{color:var(--muted);text-align:center;font-size:12.5px;margin-top:40px}
a{color:var(--accent)}
</style>
</head>
<body>
<header>
<h1>DWS Shortcut 完成情况报告</h1>
<div class="sub">对齐基准 larksuite/cli · 执行底座 钉钉 MCP · 随 loop 持续更新</div>
<div class="stats">
<div class="stat"><div class="n">366</div><div class="l">shortcut 总数</div></div>
<div class="stat"><div class="n">298</div><div class="l">1:1 封装层</div></div>
<div class="stat"><div class="n">68</div><div class="l">真·智能层</div></div>
<div class="stat"><div class="n">16</div><div class="l">覆盖服务</div></div>
<div class="stat"><div class="n">0</div><div class="l">失败/panic/编造</div></div>
</div>
</header>
<div class="wrap">
<h2><span class="ico">①</span>做了什么:两个层次</h2>
<p>诚实区分——shortcut 分两层,价值定位不同,不混为一谈。</p>
<div class="two">
<div class="layer l-wrap">
<h3>🔵 1:1 封装层 · 298 条</h3>
<div style="color:var(--muted);font-size:13.5px">一个 shortcut ≡ 一个 MCP tool。把裸 <code>dws mcp &lt;svc&gt; &lt;tool&gt; --json '{…}'</code> 收敛成命名 flag,附校验/风险确认/Intent。</div>
<div style="margin:10px 0"><b>价值</b>:DX 与 AI-agent 可发现性,<b>不是新能力</b>。</div>
<div><span class="pill">命名 flag</span><span class="pill">required/enum 校验</span><span class="pill">风险确认</span><span class="pill">自然语言 Intent</span><span class="pill">dry-run/format</span></div>
</div>
<div class="layer l-smart">
<h3>🟣 真·智能层 · 68 条</h3>
<div style="color:var(--muted);font-size:13.5px">照 lark-cli 范式的多步/编排/智能,<b>不是 1:1</b>。框架新增 <code>CallMCPData</code>(多步取数,对标 lark <code>CallAPITyped</code>)+ <code>resolveUser</code>(名→ID,对标 <code>ResolveOpenIDsTyped</code>)。</div>
<div style="margin:10px 0"><b>价值</b>:<b>这才是「shortcut 作为新能力」</b>。</div>
<div><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">按名解析+消歧</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">多工具编排</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">失败回滚</span><span class="pill" style="background:#241a3a;color:#c9b3ff;border-color:#4a3a6b">跨服务</span></div>
</div>
</div>
<h2><span class="ico">②</span>真·智能层 68 条明细(节选)</h2>
<table>
<thead><tr><th>shortcut</th><th>多步/智能逻辑</th><th class="c">验证</th></tr></thead>
<tbody>
<tr><td><code>chat +dm --to &lt;名&gt;</code></td><td>搜人→解析 userId→发单聊;多人消歧</td><td class="c ok">真机 dry-run</td></tr>
<tr><td><code>contact +lookup --name &lt;名&gt;</code></td><td>搜人→解析→取完整资料</td><td class="c ok">✅ 真机端到端</td></tr>
<tr><td><code>todo +assign --to &lt;名&gt;</code></td><td>解析人→建待办并设执行人</td><td class="c ok">真机 dry-run</td></tr>
<tr><td><code>contact +org --name &lt;名&gt;</code></td><td>解析人→取 deptId→查部门详情(3 步)</td><td class="c ok">✅ 真机端到端</td></tr>
<tr><td><code>contact +team --name &lt;名&gt;</code></td><td>解析人→取部门→列部门成员</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>calendar +free --who &lt;名&gt;</code></td><td>解析人→查其时段忙闲</td><td class="c ok">✅ 真机端到端</td></tr>
<tr><td><code>calendar +book [--with &lt;名CSV&gt;]</code></td><td>建日程→按名加参与者→<b>失败回滚删日程</b></td><td class="c ok">真机 dry-run</td></tr>
<tr><td><code>calendar +invite --event --with</code></td><td>解析多人→加入已有日程</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>calendar +suggest-time --with</code></td><td>解析多人→推荐可开会时间</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>calendar +today</code></td><td>算今天范围→列我今天日程</td><td class="c ok">✅ 真机端到端</td></tr>
<tr><td><code>calendar +next-event</code></td><td>近 7 天→取最近一个日程</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>calendar +reschedule --event</code></td><td>查日程详情→改时间</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>chat +send-to-group --group &lt;群名&gt;</code></td><td>按群名搜群→消歧→发消息</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>chat +group-members --group &lt;群名&gt;</code></td><td>搜群→列群成员</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>chat +broadcast --to &lt;名CSV&gt;</code></td><td>多名逐一解析→群发单聊,失败汇总</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>todo +todo-done --task &lt;关键词&gt;</code></td><td>列我待办→按标题匹配→标完成</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>todo +remind --task --at</code></td><td>给自己建带提醒的待办</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>wiki +wiki-new-doc --space &lt;名&gt;</code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
<tr><td><code>doc +share-doc --to &lt;名&gt; --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
</tbody>
</table>
<div class="card">
<b>质量亮点(agent 严守 ground truth)</b>:<code>+send-to-group</code> 纠正了「按群名搜群」的正确工具(<code>search_groups</code> 而非按成员昵称的 <code>search_common_groups</code>);<code>+wiki-new-doc</code> 发现 <code>create_file</code> 在 doc server 并正确跨服务路由;<code>+mail-to</code> 因钉钉无 email 字段<b>主动 skip 拒绝编造</b>。
</div>
<h2><span class="ico">③</span>测试验证(零副作用全量)</h2>
<p>写/删命令不能真跑,用<b>假 Caller 拦截</b>——每条命令走完「解析→校验→确认→组装 MCP 调用」,捕获组装出的 <code>(product,tool,params)</code>,不真发网络。</p>
<table>
<thead><tr><th>验证项</th><th>范围</th><th>结果</th></tr></thead>
<tbody>
<tr><td><code>go build ./...</code> / gofmt / vet</td><td>全仓</td><td class="ok">✅ 0 告警</td></tr>
<tr><td>TestAllShortcutsAssemble</td><td>全部 366</td><td class="ok">✅ 322 组装真实MCP · 44 自校验 · 0 失败/panic</td></tr>
<tr><td>TestAllToolLiteralsAreReal</td><td>tool 字面量</td><td class="ok">✅ 0 编造(比对 helper ground truth)</td></tr>
<tr><td>TestAllHaveIntent</td><td>全部 366</td><td class="ok">✅ 每条均有自然语言描述</td></tr>
<tr><td>TestNoDuplicateCommands</td><td>全部</td><td class="ok">✅ 无重复 · 命名规范</td></tr>
<tr><td>usage / userdef 单测</td><td>埋点/沉淀</td><td class="ok">✅ 全通过</td></tr>
<tr><td>app 包全量回归</td><td>internal/app</td><td class="ok">✅ ~72s 通过(未破坏现有命令)</td></tr>
<tr><td>智能层真机验证(9 批)</td><td>只读/解析类 20+ 条</td><td class="ok">✅ 端到端返回真实数据、投影正确</td></tr>
<tr><td>真机抓修真实 bug</td><td>合成测试盖不住的投影/解析偏差</td><td class="ok">✅ 修复 8 处(resolve-dept/at-me/group-members/free/today/suggest-time/unread-chats/dept-members)</td></tr>
</tbody>
</table>
<p style="color:var(--muted);font-size:13px">真机验证补齐了 assemble 测试的盲区:合成响应验证不了「防御式投影是否匹配真实响应结构」。9 批真机验证抓到并修复 8 处解析/投影偏差(如 <code>+resolve-dept</code> 漏了真实容器 key <code>deptList</code>、<code>+at-me</code> 未拍平嵌套、<code>+today/+free</code> 直吐冗长 raw)。少数命令(chat 会话消息读、minutes)因 org/PAT 权限受限无法真机跑通,组装链路仍由 assemble 测试覆盖。</p>
<h2><span class="ico">④</span>效果评估 GSB(vs lark-cli)</h2>
<h3>4.1 能力覆盖 GSB(按 dws 实际暴露的 MCP tool 数 = helper∪shortcut,非 shortcut 数)</h3>
<table>
<thead><tr><th>lark 服务</th><th class="c">lark</th><th>dws</th><th class="c">dws</th><th class="c">GSB</th><th>说明</th></tr></thead>
<tbody>
<tr><td>im</td><td class="c">21</td><td>chat</td><td class="c">95</td><td class="c g">G</td><td>群/消息/机器人更全</td></tr>
<tr><td>mail</td><td class="c">21</td><td>mail</td><td class="c">43</td><td class="c g">G</td><td>覆盖更广</td></tr>
<tr><td>doc</td><td class="c">14</td><td>doc</td><td class="c">34</td><td class="c g">G</td><td>块级读写更细</td></tr>
<tr><td>minutes</td><td class="c">14</td><td>minutes</td><td class="c">25</td><td class="c g">G</td><td>录音/说话人更全</td></tr>
<tr><td>calendar</td><td class="c">12</td><td>calendar</td><td class="c">24</td><td class="c g">G</td><td>会议室/ACL 更全</td></tr>
<tr><td>contact</td><td class="c">2</td><td>contact</td><td class="c">15</td><td class="c g">G</td><td>部门/角色/花名册更全</td></tr>
<tr><td>wiki</td><td class="c">12</td><td>wiki</td><td class="c">16</td><td class="c g">G</td><td>略优</td></tr>
<tr><td>base</td><td class="c">87</td><td>aitable</td><td class="c">79</td><td class="c s">S</td><td>持平;helper 仅 16,<b>shortcut 补齐 +63</b>(真 gap-fill)</td></tr>
<tr><td>task</td><td class="c">18</td><td>todo</td><td class="c">20</td><td class="c s">S</td><td>持平</td></tr>
<tr><td>drive</td><td class="c">26</td><td>drive</td><td class="c">25</td><td class="c s">S</td><td>持平</td></tr>
<tr><td>apps</td><td class="c">63</td><td>devapp</td><td class="c">25</td><td class="c b">B</td><td><b>helper 无 devapp,25 全由 shortcut 补</b>,但仍少于 lark</td></tr>
<tr><td>sheets</td><td class="c">84</td><td>sheet</td><td class="c">60</td><td class="c b">B</td><td>钉钉表格 MCP 较少;helper 已覆盖</td></tr>
<tr><td>vc/okr/slides/markdown/whiteboard/note/event</td><td class="c">62</td><td>—</td><td class="c">0</td><td class="c b">B</td><td>钉钉无对应能力,<b>客观不可对齐</b></td></tr>
</tbody>
</table>
<div style="color:var(--muted);font-size:13px;margin:6px 0 2px">🟢 G 7 领域 · 🟡 S 3 领域 · 🔴 B(apps/sheets 少于 lark + 6 领域钉钉无能力)。base 的"持平"几乎全靠 shortcut gap-fill(helper 仅 16)。dws 独有:oa/attendance/report/ding/aisearch/live/devdoc。</div>
<h3>4.2 组合/智能层 GSB(关键发现)</h3>
<div class="keyfind">
<b>关键结论</b>:lark 有 ~104 个组合(≥2 次 API)shortcut,但 <b>lark 的组合性多源于飞书 REST API 太细粒度</b>(要先查 spreadsheetToken→sheetId→再操作);<b>钉钉 MCP 是粗粒度的——一个 tool = 一个完整操作</b>,所以 lark 的组合在钉钉这边<b>大量塌缩成 1:1</b>(已被封装层覆盖),或<b>根本没有对应 tool</b>。
</div>
<table>
<thead><tr><th>lark 组合来源</th><th class="c">数量</th><th class="c">GSB</th><th>钉钉现实</th></tr></thead>
<tbody>
<tr><td>sheets(先解析 sheetId)</td><td class="c">41</td><td class="c s">S</td><td>钉钉直接吃 token → 1:1 层已覆盖</td></tr>
<tr><td>apps db-env/audit/log/trace</td><td class="c">17</td><td class="c b">B</td><td>钉钉无对应工具</td></tr>
<tr><td>drive/doc/im(上传/媒体/搜索)</td><td class="c">23</td><td class="c s">S</td><td>多为钉钉 1:1 已覆盖</td></tr>
<tr><td>calendar/contact/wiki/minutes/todo 编排</td><td class="c">~10</td><td class="c g">G</td><td>✅ 已建为真·智能 shortcut(+book/+lookup/+org/+wiki-new-doc/+reschedule…)</td></tr>
<tr><td>okr/whiteboard/slides/vc</td><td class="c">11</td><td class="c b">B</td><td>钉钉无对应能力</td></tr>
</tbody>
</table>
<div style="color:var(--muted);font-size:13px">→ 钉钉真正需要「组合」的场景(按名解析+多工具编排+跨服务),dws 已覆盖并<b>额外做了 lark 没有的</b>(+today/+broadcast/+share-doc/+action-items 等)。<b>不盲目复刻 lark 的机械多步</b>(在钉钉会成冗余假组合)。</div>
<h2><span class="ico">⑤</span>dws 差异化优势</h2>
<div class="card"><b>复用生产级 MCP 通道</b>(架构性)—— <code>CallMCP</code> 统一继承错误分类(auth/PAT/业务)、dry-run、<code>--format/--jq/--fields</code>;lark 每命令各自实现。</div>
<div class="card"><b>协作能力覆盖更全</b> —— chat 95/mail 43/doc 34/minutes 25(dws tool 覆盖)是 lark 对应 2–4 倍。</div>
<div class="card"><b>钉钉原生特有能力</b>(lark 完全没有)—— 审批/日志/考勤/DING/企业智能搜索,75 条差异化封装。</div>
<div class="card"><b>真·智能编排(22 条)</b> —— 按名解析+消歧、失败回滚、跨服务;<code>resolveUser</code>/<code>CallMCPData</code> 让新智能 shortcut 越写越快。</div>
<div class="card"><b>高频自动沉淀(P2 · lark 无此设计)</b>
<div class="flow" style="margin-top:9px"><b>高频使用</b><span class="arw">→</span><b>埋点</b><span class="arw">→</span><b>suggest</b><span class="arw">→</span><b>add 写 YAML</b><span class="arw">→</span><b>运行时加载可用</b></div>
</div>
<div class="card"><b>工程质量</b> —— 假 Caller 拦截,366 条(含写/删)零副作用全量验证,可复跑回归。</div>
<div class="concl">
<b>一句话结论</b>:dws 已把钉钉侧<b>能对齐的都对齐</b>(366 条 = 298 封装 + 68 智能 / 16 服务,1:1 层已去 213 条纯重复),即时协作显著优于 lark,拥有审批/考勤/DING 等原生差异化能力与「高频自动沉淀」独有闭环。lark 的组合优势多因飞书 API 细粒度、在钉钉粗粒度 MCP 下塌缩为 1:1(已覆盖),真正需编排的钉钉侧已建齐;受限项均为钉钉客观无对应能力,非工程遗漏。全部 366 条通过零副作用全量验证。
</div>
<footer>DWS Shortcut Report · 由持续精进 loop 维护 · 另见 <a href="shortcut-comparison.html">逐条三方对照 HTML</a> · <a href="shortcut-report.md">Markdown 版</a></footer>
</div>
</body>
</html>
+289
View File
@@ -0,0 +1,289 @@
# DWS Shortcut 能力整合与对齐报告
> 版本:截至本轮 loop | 对齐基准:larksuite/cli(lark-cli) | 执行底座:钉钉 MCP
> 相关文档:[总规划](shortcut-plan.md) · [P2 自动沉淀设计](shortcut-p2-design.md) · [HTML 报告](shortcut-report.html) · [**逐条三方对照 HTML**](shortcut-comparison.html)(每个 shortcut:dws +命令 vs lark-cli vs 原生 MCP 组合)
---
## 1. Shortcut 整合了哪些能力
`dws` 现内建 **298 个 1:1 封装 shortcut** + **68 条 smart 智能编排命令**(§1.3)= **合计 366 条**,覆盖 **16 个钉钉服务**,以 `dws <service> +<command>` 形式提供。
> ⚠️ **重要修订(去冗余)**:1:1 层原为 511 条,**复盘发现 `internal/helpers/` 早已把大量 MCP tool 封装成 `dws <svc> <verb>` 产品命令**——其中 **213 条 1:1 shortcut 只是把已被 helper 封装过的同一个 tool 用 `+` 前缀又封了一遍、且无输出投影增量,属纯重复**,已删除。**保留的 298 条 = 233 条填 helper 空白(helper 从没封装的 tool)+ 65 条虽 tool 重复但加了干净投影**。这是对"建 1:1 层前没先摸清 helper 已封装什么"的纠偏(详见 §5 复盘)。aisearch/live/devdoc 三个服务的 shortcut 全属纯重复、已整包移除(其 `dws <svc>` 命令仍由 helper 层提供)。
### 1.1 能力清单(按服务,prune 后)
| 服务 | 1:1 shortcut 数 | 覆盖能力(摘要) |
|------|:---:|------|
| chat(群聊/消息) | 79 | 群管理、群成员、群身份角色、消息收发/撤回/转发/表情/卡片、会话置顶/免打扰、消息分组、机器人 |
| aitable(多维表 base) | 77 | 数据表/字段/记录/视图/表单/仪表盘/图表/角色/协作全生命周期 |
| attendance(考勤)★ | 33 | 打卡记录、审批、排班、班次、考勤组、统计报表、请假 |
| devapp(开放平台应用 apps) | 30 | 应用增删改查、成员、权限、版本发布、事件订阅、扩展机器人/H5 配置 |
| doc(文档) | 16 | 文档/文件夹、正文块读写、权限、附件、节点 |
| contact(通讯录) | 9 | 用户/部门搜索与详情、角色、花名册 |
| drive(钉盘) | 8 | 文件/文件夹管理、下载、复制移动、权限、最近访问 |
| calendar(日历) | 8 | 日程、参与人、会议室、忙闲、ACL、日历本 |
| minutes(AI 听记) | 7 | 妙记详情/逐字稿、录音控制、说话人 |
| oa(审批)★ | 6 | 审批实例、单据处理、模板、流程 |
| mail(邮箱) | 6 | 邮件搜索/线程、标签、联系人、收信规则(投影类保留) |
| wiki(知识库) | 5 | 知识空间、节点、成员 |
| todo(待办 task) | 5 | 待办、子任务、执行人/参与人、附件 |
| ding(DING)★ | 5 | 机器人/个人 DING 发送、撤回、接收状态 |
| sheet(钉钉表格) | 2 | 区域读写(投影类保留) |
| report(日志)★ | 2 | 日志收件箱/发件箱 |
| **合计** | **298** | **16 个服务** |
★ = 钉钉特有服务,lark-cli 无对应(详见 §3 GSB)。**注**:多数服务的 `shortcut 数` 已远小于该服务的 MCP tool 总数——因为 tool 的基础封装由 helper 层的 `dws <svc> <verb>` 命令承担,1:1 shortcut 只保留 helper 没覆盖的、或加了投影的。
### 1.2 每个 shortcut 统一具备的能力(框架注入)
不是简单命令别名,而是叠加在裸 MCP 之上的**精选薄封装**,统一获得:
- **声明式定义**:`Shortcut{Service, Command, Product, Risk, Flags, Execute}`,一处声明、框架编译成 cobra 命令。
- **自然语言 Intent**:每条 shortcut 均带一段自然语言描述(做什么/何时用/关键输入产出,写删类点明副作用),面向用户与 AI agent 的意图匹配;`--help` 展示为长描述,`dws shortcut list` 输出 `intent` 字段。全部 366 条覆盖(`TestAllHaveIntent` 强制校验)。
- **参数收敛**:把裸 `dws mcp <svc> <tool> --json '{...}'` 的手拼 JSON,收敛成命名 flag(`--query`/`--group`…)。
- **内建校验**:required / enum 声明式校验,结构化错误提示。
- **风险确认**:read / write / high-risk-write 分级,写/删操作 `--yes` 前二次确认。
- **复用生产级 MCP 通道**:错误分类(auth/PAT/业务)、`--dry-run` 预览、`--format`/`--jq`/`--fields` 输出,全部免费继承(详见 §4)。
### 1.3 两个层次:1:1 封装层 vs 真·多步/智能层(重要澄清)
诚实区分——上面 298 条**绝大多数是 1 shortcut ≡ 1 个 MCP tool 的 1:1 封装**,本质是「给 MCP 套命名 flag + 校验 + Intent 的友好外壳」,价值在 DX 与 agent 可发现性,**不是新能力**。
真正的「shortcut 作为新能力」是 `internal/shortcut/smart/` 下的**多步/智能** shortcut——照 larksuite/cli 的实现范式(`CallAPITyped` 链式多步、按名解析 ID、Validate、DryRun 计划、失败回滚)落地。框架为此新增 `RuntimeContext.CallMCPData(product, tool, params)`(对应 lark 的 `CallAPITyped`:调用并返回 data 供下一步,跨服务)。
已落地的真·智能 shortcut(`internal/shortcut/smart/`,共 68 条,下表为代表性节选):
| shortcut | 多步/智能逻辑 | 验证 |
|----------|--------------|------|
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
| `calendar +suggest-time --with <姓名CSV>` | 解析多人→推荐可开会时间 | ✅ 编译/挂载 |
| `calendar +invite --event <id> --with <姓名CSV>` | 解析多人→加入已有日程 | ✅ 编译/挂载 |
| `doc +share-doc --to <姓名> --url` | 解析人→把文档链接私信 TA | ✅ 编译/挂载 |
| `calendar +today` | 算出今天时间范围→列我今天的日程 | ✅ **真机端到端**(返回真实日程+参会人) |
| `calendar +next-event` | 近 7 天日程→按时间取最近一个 | ✅ 编译/挂载 |
| `contact +team --name <姓名>` | 解析人→取部门→列部门直接成员 | ✅ 编译/挂载 |
| `todo +remind --task --at` | 给自己建带截止/提醒时间的待办 | ✅ 编译/挂载 |
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
| `oa +approve-by --keyword` ★ | 列待审批→匹配→取 taskId→通过(钉钉原生,lark 无) | ✅ 编译/挂载 |
| `attendance +my-attendance` ★ | 当前用户→算今天→查我打卡(路由 attendance-wukong server) | ✅ 编译/挂载 |
| `todo +overdue` | 列我待办→本地过滤过期→投影输出 | ✅ **真机端到端** |
| `report +report-latest` ★ | 列我日志→取最新→取详情 | ✅ 编译/挂载 |
| `aitable +find-record --base --table` | 表内按关键词查记录 | ✅ 编译/挂载 |
另有 gap-buildable 补齐(批6):`chat +my-groups`(列群+类型过滤+投影)、`calendar +find-room`(时段找可用会议室)、`minutes +minutes-search`(关键词搜妙记)、`mail +search-mail`(搜邮件+自动解析绑定邮箱)、`drive +find-file`(搜钉盘文件+投影)。
批7-8 续补(10 条):`chat +at-me`(近期@我)、`calendar +cancel-event`(查→删,高危二次确认)、`todo +assign-multi`(多人指派)、`contact +dept-members`(搜部门→列成员)、`minutes +transcript`(最新妙记逐字稿)、`calendar +week`(本周日程)、`contact +by-mobile`(手机号→资料)、`todo +created-todos`(我创建的)、`chat +unread-chats`(未读会话)、`mail +unread-mail`(未读邮件)。
批9 续补(3 条·手工,对齐 gap-buildable):`minutes +detail`(单命令聚合一条听记的 basic/summary/keywords/transcript/todos,partial-failure 容错)、`minutes +replace-batch`(多组 `原文=>替换` 批量替换 + 去重校验 + 逐组结果聚合,补齐一次一组的 1:1 `+word-replace`)、`aitable +record-share-links`(>20 条记录分享链接:去重+分片(≤20/批)+跨 `aitable-helper` server fanout+合并,补齐单批 20 条上限)。
批10 续补(3 条·多 agent 并行,对齐 gap-buildable):`chat +thread-replies`(拉某条话题消息的全部回复 list_topic_replies + sender/text/time 投影)、`todo +related-tasks`(creator+executor+participant 三角色并集「与我相关的待办」+ taskId 去重 + 投影)、`doc +find-doc`(按关键词搜云文档 search_documents + title/url/type/token 投影)。
批11 续补(3 条·多 agent 并行):`aitable +resolve-base`(按名搜 Base 解析 baseId,0/1/多候选消歧 search_bases)、`chat +chat-messages`(群/单聊会话消息列表,list_conversation_message_v2 / list_individual_chat_message 互斥+投影)、`mail +find-mail-user`(按名/邮箱搜企业邮箱联系人 search_mail_users + 投影)。
批12 续补(3 条·多 agent 并行,dws 原生 resolver 层,按名解析 ID):`wiki +resolve-space`(search_wikiSpaces 名→spaceId)、`aitable +resolve-table`(get_tables 在 Base 内名→tableId,本地匹配)、`contact +resolve-dept`(search_dept_by_keyword 名→deptId,含数值 ID 兼容)。均 0/1/多候选消歧,对标 `resolveUser` 的各资源版。
批28 续补(3 条·净新增便利读,dws 原生):`oa +pending`(**只读**列待我审批,区别于会审批的 +approve-by)、`todo +due-today`(今天到期待办,planFinishDate 服务端过滤,区别于 +overdue 已过期)、`calendar +tomorrow`(明天日程,复用 +today/+week 投影)。均只读、真机验证(+tomorrow 返回真实明日日程;+pending/+due-today 空路径正确且复用已验证 helper)。
批29 续补(3 条·净新增便利读,dws 原生):`oa +done-approvals`(我已处理的审批历史 get_done_tasks;真机抓到并修复 pageSize=0 → 默认 20 的后端报错 bug)、`mail +recent-mail`(近期收件箱会话 list_mailbox_threads + 解析绑定邮箱/收件箱)、`attendance +this-month`(本月打卡 query_check_record on attendance-wukong,复用 +my-attendance 自身解析)。真机:+this-month 返回有效空、+done-approvals 修后走空路径、+recent-mail 正确报未绑定邮箱。
批30 续补(1 条·净新增便利读):`contact +me`(当前用户 get_current_user_profile + 投影 {name,userId,mobile,dept,org,email},agent 的「我是谁」;区别于 1:1 +get-self 吐冗长 raw,真机验证 董鑫阳/202397/模型算法)。
批31 续补(1 条·净新增便利读):`calendar +my-free`(我自己的忙闲,自动解析当前 userId,默认今天,复用 +free 的 freebusySlots 投影;无需像 +free 传别人姓名,真机验证返回今日忙碌时段)。
批32 续补(1 条·净新增 dws 原生编排,lark 也没有):`calendar +conflicts`(检测某天日程时间冲突/双重预订,list_calendar_events + 本地两两重叠检测,默认今天/--in-days;真机验证抓到今日 2 处真实冲突)。这类纯 MCP-tool 的本地编排是复杂写死胡同之外仍有价值的方向。
批33 续补(1 条·净新增 dws 原生编排,+conflicts 的互补品):`calendar +free-slots`(找某天工作时段内的空闲时段"什么时候能安排会",list_calendar_events + 合并忙碌区间 + 工作窗口内求补集,默认今天 09:00-18:00/--from/--to/--in-days;真机验证今日 4 段空档)。
共 **68 条真·智能 shortcut**(多批多 agent 工作流并行生成 + 手工续补)。★=钉钉原生编排,lark 完全没有。
**真机验证(登录态抽样,返回真实数据)**:`calendar +today/+week`(真实日程+投影)、`contact +org`(3 步→部门详情)、`contact +lookup/+free`、`todo +overdue`、`attendance +my-attendance` 等端到端可用。
### 深度对齐矩阵(逐条分析 lark 361 条 shortcut)
见 [`shortcut-lark-alignment.md`](shortcut-lark-alignment.md)——12 agent 逐条深读 lark 每个 shortcut 的智能实现(Validate/DryRun/ID解析/投影/多步/分页),映射钉钉:
| dws_status | 数量 | 含义 |
|---|:---:|---|
| covered-1to1 | 144 (40%) | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
| no-dingtalk-tool | 127 (35%) | 钉钉无对应工具,客观不可对齐 |
| **gap-buildable** | **42 (12%)** | 钉钉有工具、值得补成智能 shortcut(建设目标) |
| covered-smart | 48 (13%) | 已建智能 shortcut / 部分覆盖 |
**框架系统性能力已对齐 lark**:`resolveUser`(名→ID)· `CallMCPData`(多步取数)· `rt.Output`(输出投影)· `rt.MutuallyExclusive/AtLeastOne/ExactlyOne/RangeInt/RequireAll`(跨字段校验)。
### 保真度升级(对齐 lark 96% 的输出投影)
lark 96% 的 shortcut 都做**输出投影**(把原始 API 返回精简为干净字段列表)。已给 **~60 条列表/读类封装**升级到此保真度——从 `rt.CallMCP`(打印原始 MCP 返回)改为 `rt.CallMCPData` + 防御式投影 + `rt.Output`(自动吃 `--format/--jq/--fields`):
`contact +search-user/+search-mobile/+list-roles/+list-sub-depts` · `todo +get-my-tasks/+list-sub` · `calendar +book-list/+attendee-list` · `drive +list` · `wiki +node-list` · `chat +conversation-list/+category-list/+messages-list-unread-conversations/+messages-list-pin` · `doc +search/+list` · `mail +tag-list/+contact-list` · `aitable +base-list/+base-search` · `oa …`
示例:`contact +search-user` 由原始 MCP 返回 → 干净 `{count, users:[{name,userId,flowerName,openDingTalkId,title}]}`(真机验证)。这是把封装层往 lark 高保真水平系统性拉升的开始。另有 `mail +to`(按名发邮件)被 agent **正确 skip**——钉钉 contact 无 email 字段、mail `send_email` 需发件人邮箱 `from` 无法解析,宁缺勿错不编造。关键复用:「按名解析人」抽成共享 helper `resolveUser`(对标 lark `ResolveOpenIDsTyped`,带 0/多人消歧,不瞎猜);多步靠 `CallMCPData`(对标 lark `CallAPITyped`)。其中 5 条由多 agent 工作流并行生成——各自以 helper 为 ground truth 研究参数、`send-to-group` 的 agent 还主动纠正了「按群名搜群」的正确工具(`search_groups` 而非按成员昵称的 `search_common_groups`)。
> 定位:1:1 层是「MCP 友好外壳」,smart 层才是「真 shortcut」。二者不混淆。
---
## 2. 测试验证报告
**验证理念**:写/删命令不能真跑(会发消息、解散群、删数据),故用**假 Caller 拦截**——让每条命令(含写/删)真实走完「解析→校验→确认→组装 MCP 调用」全流程,捕获组装出的 `(product, tool, params)`,只是不真发网络。以此对**全部 366 条**做零副作用验证。
### 2.1 结果总览(全绿)
| 验证项 | 范围 | 结果 |
|------|------|------|
| `go build ./...` | 全仓 | ✅ 通过 |
| `gofmt -l` / `go vet` | shortcut 全包 | ✅ 0 未格式化 / 0 告警 |
| 框架单元测试(5) | 类型/挂载/校验/分组 | ✅ 全通过 |
| **TestAllShortcutsAssemble** | **全部 366 条** | ✅ 322 组装真实 MCP · 44 自校验拦截 · **0 失败 · 0 panic** |
| **TestAllToolLiteralsAreReal** | 全部 tool 字面量(逐条) | ✅ **0 编造**(tool 名逐一比对 helper ground truth) |
| TestNoDuplicateCommands | 全部 366 条 | ✅ 无重复、命名规范(均 `+` 前缀) |
| **TestAllHaveIntent** | 全部 366 条 | ✅ 每条均有自然语言 Intent 描述(无一遗漏) |
| usage 包单测(4) | 埋点/脱敏/聚合/开关 | ✅ 全通过 |
| app 包全量回归 | `internal/app` | ✅ 72.2s 通过(接线未破坏任何现有命令) |
| 只读命令真机验证 | ~25 条(登录态,见 §2.4) | ✅ `contact +me`/`+org`/`+lookup`、`calendar +today/+week/+free/+conflicts/+free-slots`、`doc +find-doc`、`aitable +resolve-base/+resolve-table`、`drive +find-file`、`oa +my-initiated` 等端到端返回真实数据、投影核对 |
### 2.2 关键指标解读
- **322 「组装真实 MCP」**:喂合成参数后成功组装出 MCP 调用,且 tool 名经 helper ground truth 核验真实、非编造。
- **44 「自校验拦截」**:这些命令有结构化/JSON/互斥输入(如多维表建记录需 JSON、DING 三选一接收人),dummy 值被其**自身校验正确拒绝**——证明校验链路健全。其 tool 名由静态测试 `TestAllToolLiteralsAreReal` 单独覆盖,无遗漏。
- **0 编造 / 0 panic / 0 失败**:无幻觉工具名,无运行时崩溃,无死命令。
> ⚠️ **验证强度分层(诚实口径,勿把"全绿"读成"真机全对")**:`TestAllShortcutsAssemble` 的"0 失败"只证明**能正确组装 MCP 调用、零副作用**——它用**合成响应**,**验证不了防御式投影是否匹配真实响应结构**(真机验证正是靠这个抓到过 deptList 容器、pageSize=0 等 assemble 盖不住的 bug,见 §2.4)。按真机验证强度分三层:**(A) 真机正向验证** ~25 条只读/解析类(返回真实数据、投影核对);**(B) 仅 assemble + 复用已验证 helper**(如 +due-today/+this-month 等,逻辑同构于已验证命令,但该条本身未在真机跑出正样本);**(C) 未对真实后端跑过**——17 条写类 smart(不宜真跑,会发消息/建数据)、6 条 minutes smart(该 org 未开 CLI 数据访问)、mail +recent-mail(无绑定邮箱)。(C) 类**很可能仍有 assemble 盖不住的投影/参数 bug**,不应因"全绿"就当作"真机可用"。
### 2.3 防幻觉机制(工作流生成时)
生成阶段每个服务由独立 agent 负责,硬性规则:tool 名与参数 key **只能逐字取自 dws helper 的真实调用点**,无法确定参数的 tool 主动跳过并记录原因(如嵌套对象、时间戳转换、本地文件分片上传)。测试阶段再用 ground truth 二次核验,双重保险。
### 2.4 真机验证战役(登录态打真实钉钉后端)
assemble 测试用**合成响应**,能验证「调用是否组装正确」,但验证不了「防御式投影解析是否匹配真实响应结构」。为此做了 9 批真机验证(登录态 corp「钉钉」,token 有效期内),把只读/解析类 smart shortcut 打真实后端、逐条核对投影输出。
**正向验证 20+ 条**(返回真实数据、投影正确):`doc +find-doc`、`aitable +resolve-base`/`+resolve-table`/`+list-tables`/`+base-list`/`+find-record`、`mail +find-mail-user`、`chat +my-groups`/`+group-members`/`+at-me`、`contact +org`/`+lookup`、`calendar +today`/`+week`/`+next-event`/`+free`/`+suggest-time`、`todo +overdue`/`+related-tasks`、`attendance +my-attendance`、`report +report-latest`、`oa +my-initiated`、`drive +find-file`、`wiki +resolve-space` 等。
**真机抓到并修复 8 个真实问题**(assemble 测试抓不到,只有真机能抓):
| shortcut | 真机发现的问题 | 修复 |
|---|---|---|
| `contact +resolve-dept` | 对任何真实部门名都「未找到」——真实响应容器 key 是 `deptList`(防御探测清单漏了),deptName 带 `<red>` 高亮、deptId 是数值 | 加 `deptList` 探测 + `stripHighlightTags` + 数值 coerce |
| `contact +dept-members` | 消歧消息泄漏 `<red>` 标记 | 复用 `stripHighlightTags` |
| `chat +unread-chats` | 每行吐 `unread: null`(底层不返回每会话未读数) | 仅当有值才带该字段 |
| `chat +at-me` | 直吐原始两层嵌套、未拍平 | 新增 `atMeFlattenGroups`,拍平 43 条为 `{conversation,sender,text,time}` |
| `chat +group-members` | 终结步 raw `CallMCP` 吐冗长 raw(含 avatar 媒体 ID + errorCode 噪音) | 升级 `CallMCPData`+投影 `{name,nick,role,openDingtalkId}` |
| `calendar +free` | 吐冗长 `result[].scheduleItems[].{start,end}.dateTime` 嵌套 | 投影为 `{who,userId,free,busy:[{start,end}]}` |
| `calendar +today` | 吐 17 字段冗长事件(含完整 attendees 数组),与 `+week` 不一致 | 投影为 `{title,start,end,location,eventId}`,对齐 `+week` |
| `calendar +suggest-time` | `timeConflictAttendees:[null]` 噪音 + result 包裹 | 拍平 + 丢 null 冲突 → `{suggestions:[{start,end}]}` |
**后端受限、无法真机正向验证的(非代码问题)**:`chat +chat-messages`/`+search-msg`(读会话消息需更高 PAT 权限 / org 未开 CLI 数据访问)、`minutes +*`(org 未开 CLI 数据访问 `TOKEN_VERIFIED_FAILED`)、`contact +team`(列部门成员 medium-risk 权限墙)。这些命令的**组装链路**经 assemble 测试验证正确,仅无法在本环境跑通后端。
**一个已澄清的非 bug**:`resolveUser` 对组织内成员(如董鑫阳→userId 202397)真机端到端正常;对外部/资料受限联系人 `search_contact_by_key_word` 只返回 openDingTalkId(name/userId 全 null),此时正确报「没找到」而非瞎猜——钉钉数据模型现实,非代码缺陷。
> **结论**:真机验证证明「防御式多候选 key 投影」在真实响应上整体成立,并纠正了 8 处「合成测试盖不住」的解析/投影偏差。这是把可用性从「组装正确」提升到「真机输出正确」的关键一环。
---
## 3. 效果评估 GSB(vs lark-cli)
以 lark-cli 各服务领域为基准,评估 dws shortcut 的相对表现。**G**ood=优于/更全,**S**ame=持平,**B**ad=弱于/缺失。
> ⚠️ 重要前提:两边是**不同 API**(飞书 vs 钉钉),数量不能机械 1:1;覆盖度受钉钉实际能力约束。
>
> **口径(prune 后重做)**:不再用 shortcut 数(会因去冗余失真),改用**「dws 该服务实际暴露的 distinct MCP tool 数」= helper 命令 ∪ shortcut 覆盖的 tool 合集**——这才代表真实能力,与 helper/shortcut 怎么分层无关。对比 lark 的 shortcut 数(不同 API,只作量级参考)。
| lark 服务 | lark 数 | dws 对应 | dws tool 覆盖 | (其中 shortcut 补) | GSB | 说明 |
|-----------|:---:|---------|:---:|:---:|:---:|------|
| im | 21 | chat | **95** | +3 | 🟢 G | 群/消息/机器人能力更全 |
| mail | 21 | mail | **43** | +0 | 🟢 G | 覆盖更广(几乎全由 helper 提供,shortcut 曾重复、已 prune) |
| doc | 14 | doc | **34** | +4 | 🟢 G | 块级读写更细 |
| minutes | 14 | minutes | **25** | +0 | 🟢 G | 录音控制/说话人更全 |
| calendar | 12 | calendar | **24** | +5 | 🟢 G | 会议室/ACL/忙闲;shortcut 另补排期智能 |
| contact | 2 | contact | **15** | +0 | 🟢 G | 部门/角色/花名册更全 |
| wiki | 12 | wiki | **16** | +0 | 🟢 G | 略优 |
| base | 87 | aitable | **79** | **+63** | 🟡 S | 基本持平;**helper 仅 16,shortcut 层补齐了绝大部分**(真·gap-fill) |
| task | 18 | todo | **20** | +1 | 🟡 S | 持平 |
| drive | 26 | drive | **25** | +4 | 🟡 S | 基本持平 |
| apps | 63 | devapp | **25** | **+25** | 🔴 B | **helper 无 devapp 命令、25 个全由 shortcut 提供**(纯 gap-fill),但仍少于 lark |
| sheets | 84 | sheet | **60** | +1 | 🔴 B | 钉钉表格 MCP 较少;helper 已覆盖,shortcut 曾重复、已 prune |
| vc | 18 | — | 0 | — | 🔴 B | 钉钉 conference 无干净 MCP tool |
| okr | 13 | — | 0 | — | 🔴 B | 钉钉无对应能力,**不可对齐** |
| slides / markdown / whiteboard / note / event | 17 | — | 0 | — | 🔴 B | 钉钉无对应能力,**不可对齐** |
**dws 独有(lark 无对应服务)**:oa 审批(~20 tool) · attendance 考勤(~38) · report 日志(~7) · ding(~8) · aisearch/live/devdoc(helper 层提供)——钉钉工作流核心,构成差异化。
### GSB 汇总
- 🟢 **G(7 领域)**:im/mail/doc/minutes/calendar/contact/wiki——dws tool 覆盖更全。
- 🟡 **S(3 领域)**:base/task/drive 量级持平(base 的持平**几乎全靠 shortcut 层 gap-fill**,helper 只有 16)。
- 🔴 **B(受限 2 + 不可对齐 6)**:apps/sheets 少于 lark(sheets 受钉钉 API 限,apps 全由 shortcut 补但仍少);vc/okr/slides/markdown/whiteboard/note/event 客观不可对齐(非遗漏)。
- **注**:这张表也印证了 1:1 层的真实价值分布——**base/apps 靠 shortcut 补了大量 helper 没有的 tool(gap-fill),而 mail/sheets 的 shortcut 基本是重复 helper(已 prune)**。
---
## 4. dws 差异化于 lark 的优势
### 4.1 执行底座:复用生产级 MCP 通道(架构性优势)
lark-cli 每个 shortcut 直连飞书 SDK,错误处理/输出各自实现。dws shortcut 的 `CallMCP` **委托统一的 MCP 调用路径**,一步继承:
- **错误分类**:auth 过期 / 未登录 / PAT / 业务错误,自动给出可执行提示;
- **`--dry-run` 预览**:不发网络,输出将执行的 tool + 参数;
- **`--format`/`--jq`/`--fields`**:机器可解析输出,Agent 友好。
lark 需在每个命令重复实现这些;dws 由框架统一注入,一致性与维护成本双赢。
### 4.2 覆盖更全的高频协作能力
按 **dws tool 覆盖**(helper∪shortcut,§3 口径):chat 95 / mail 43 / doc 34 / minutes 25 / calendar 24 等即时协作场景,多为 lark 对应服务的 2–4 倍。
### 4.3 钉钉原生特有能力(lark 完全没有)
审批 oa、日志 report、考勤 attendance、DING、企业智能搜索 aisearch —— 这些是钉钉工作流的核心,构成差异化护城河。
### 4.4 高频自动沉淀(P2,lark 无此设计)
基于用户高频使用**主动把常用操作沉淀为自定义 shortcut**(`~/.dws/shortcuts/*.yaml` 运行时加载),让 CLI 越用越顺手。埋点**默认关(opt-in,`DWS_USAGE_TRACKING=1` 开启)**+开启后首跑告知,隐私优先(记形状不记值)。详见 [P2 设计](shortcut-p2-design.md)。**lark-cli 无任何等价能力。**
### 4.5 AI Agent 友好
`--yes` 跳过确认、结构化错误、`--dry-run` 预览、`--print-schema`(规划中)——为 Agent 自动化调用而设计。
### 4.6 工程质量:全量自动化测试
假 Caller 拦截,对全部 366 条(含写/删)做零副作用验证 + tool 名 ground truth 核验,可复跑、可回归。
---
## 5. 复盘与后续(loop 持续项)
### 5.0 关键复盘:1:1 层去冗余(511 → 298)
**问题**:建 1:1 shortcut 层之前,**没有先摸清 `internal/helpers/` 已经把哪些 MCP tool 封装成了 `dws <svc> <verb>` 产品命令**。结果对 **213 个 tool 重复封装**——同一个 tool,helper 有 `dws contact user search`、我又造了 `dws contact +search-user`,且这批无投影增量,纯重复。
**纠偏**:按「tool 已被 helper 封装 且 shortcut 用 CallMCP 无投影」精确删除 213 条,1:1 层 511 → **298**(保留 233 填空白 + 65 有投影),总数 579 → **366**,服务 19 → **16**(aisearch/live/devdoc 整包移除)。全绿、真机复验保留命令仍可用。
**教训**:**做封装层前先审已有封装**。对齐 lark「每一条 shortcut」时,应先问「dws 这边是不是已经有等价命令了」,而不是无脑对齐。这是本项目最大的方法论盲点。
1. ~~补 apps(↔devapp)~~ ✅ 已完成:新增 30 个 devapp shortcut。
2. **P2 落地**(差异化能力,lark 无):
- ✅ **P2-1 usage 埋点**:装饰 MCP 调用唯一必经点记录 `~/.dws/usage.jsonl`(**记形状不记值**,敏感/自由文本字段脱敏;**默认关/opt-in**,`DWS_USAGE_TRACKING=1` 开启、开启后首跑告知)。端到端验证通过。
- ✅ **stats/list**:`dws shortcut list [--service]`、`dws shortcut stats [--top N] [--purge]`(按 `(product,tool,arg_keys)` 聚合、识别固定值 fixed_args)。
- ✅ **P2-2 suggest**:`dws shortcut suggest [--min N]` 把高频分组转成「建议沉淀的 +command」候选(含固定/可变参数拆分)。
- ✅ **P2-3 YAML 自定义 shortcut 沉淀闭环**:`dws shortcut add` 写 `~/.dws/shortcuts/*.yaml` → 下次运行 `userdef.Load()` 编译成 Shortcut 注册(复用同一 runner;`${flag}` 绑定+常量;与内建冲突自动跳过)。**端到端验证通过**:add→重载→`dws <svc> +<cmd>` 可用,dry-run 组装正确。
- ⏳ **P2-4 nudge**:命中高频候选时主动提示(TTY、频控、可永久关闭)。
> 至此,**「高频使用 → 建议 → 一键沉淀 → 自定义 shortcut 运行时生效」完整闭环已打通**——这是 lark-cli 完全没有的差异化能力。
3. **深化 sheets**:随钉钉表格 MCP 能力增强补齐。
4. **实机全读回归**:登录态下对全部只读 shortcut 做真实调用回归(需有效 token + 真实资源 ID)。
5. **不可对齐项归档**:okr/slides/whiteboard/note/vc/event 明确标注为钉钉无能力,避免误解为遗漏。
---
## 附:一句话结论
> dws 已把钉钉侧**能对齐的主要服务全部对齐**(16 服务 / **366 shortcut** = 298 封装 + 68 智能编排),在即时协作能力上显著优于 lark,并拥有审批/考勤/日志/DING 等钉钉原生差异化能力与「高频自动沉淀」独有设计;受限项均为钉钉客观无对应能力,非工程遗漏。全部 366 条通过零副作用全量自动化验证。
+31 -1
View File
@@ -1,11 +1,19 @@
module github.com/DingTalk-Real-AI/dingtalk-workspace-cli
go 1.25.8
go 1.25.9
require (
github.com/Microsoft/go-winio v0.6.2
github.com/RealAlexandreAI/json-repair v0.0.15
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/fatih/color v1.18.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.0
github.com/itchyny/gojq v0.12.18
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/crypto v0.49.0
@@ -14,11 +22,33 @@ require (
)
require (
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/catppuccin/go v0.3.0 // indirect
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.20.0 // indirect
)
require (
+79
View File
@@ -1,27 +1,99 @@
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/RealAlexandreAI/json-repair v0.0.15 h1:AN8/yt8rcphwQrIs/FZeki+cKaIERUNr25zf1flirIs=
github.com/RealAlexandreAI/json-repair v0.0.15/go.mod h1:GKJi5borR78O8c7HCVbgqjhoiVibZ6hJldxbc6dGrAI=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad h1:Bb4I+suYd+ehQ8e22aimLLze+5XTN3+WTc/x2LafmH8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -31,11 +103,18 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
+233
View File
@@ -0,0 +1,233 @@
package app
import (
"bufio"
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/spf13/cobra"
)
func newAuditCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "audit",
Short: "操作审计日志管理",
Long: "查看、导出和校验本地操作审计日志。",
}
cmd.AddCommand(
newAuditTailCommand(),
newAuditExportCommand(),
newAuditVerifyCommand(),
)
return cmd
}
func newAuditTailCommand() *cobra.Command {
var n int
cmd := &cobra.Command{
Use: "tail",
Short: "查看最近的审计记录",
RunE: func(cmd *cobra.Command, args []string) error {
if n < 1 {
return fmt.Errorf("--lines 必须为正整数,收到 %d", n)
}
dir := auditDir()
file, err := audit.LatestAuditFile(dir)
if err != nil {
return fmt.Errorf("无审计记录: %w", err)
}
lines, err := tailFile(file, n)
if err != nil {
return err
}
for _, line := range lines {
fmt.Println(line)
}
return nil
},
}
cmd.Flags().IntVarP(&n, "lines", "n", 20, "显示最近 N 条记录")
return cmd
}
func newAuditExportCommand() *cobra.Command {
var since, until, format string
cmd := &cobra.Command{
Use: "export",
Short: "导出审计日志",
RunE: func(cmd *cobra.Command, args []string) error {
dir := auditDir()
sinceDate := strings.ReplaceAll(since, "-", "")
untilDate := strings.ReplaceAll(until, "-", "")
files, err := audit.AuditFilesInRange(dir, sinceDate, untilDate)
if err != nil {
return fmt.Errorf("查找审计文件失败: %w", err)
}
if len(files) == 0 {
return fmt.Errorf("指定范围内无审计文件")
}
switch format {
case "jsonl":
return exportJSONL(files)
case "csv":
return exportCSV(files)
default:
return fmt.Errorf("不支持的格式: %s(可选 jsonl, csv)", format)
}
},
}
cmd.Flags().StringVar(&since, "since", "", "起始日期 (YYYY-MM-DD)")
cmd.Flags().StringVar(&until, "until", "", "截止日期 (YYYY-MM-DD)")
cmd.Flags().StringVar(&format, "format", "jsonl", "输出格式: jsonl 或 csv")
return cmd
}
func newAuditVerifyCommand() *cobra.Command {
var file string
cmd := &cobra.Command{
Use: "verify",
Short: "校验审计日志哈希链完整性",
RunE: func(cmd *cobra.Command, args []string) error {
target := file
if target == "" {
dir := auditDir()
var err error
target, err = audit.LatestAuditFile(dir)
if err != nil {
return fmt.Errorf("无审计文件: %w", err)
}
}
valid, brokenAt, err := audit.VerifyFile(target)
if err != nil {
return fmt.Errorf("校验失败: %w", err)
}
if valid {
fmt.Printf("✓ %s 哈希链完整(全部通过)\n", filepath.Base(target))
} else {
fmt.Printf("✗ %s 哈希链在第 %d 行断裂\n", filepath.Base(target), brokenAt)
os.Exit(1)
}
return nil
},
}
cmd.Flags().StringVar(&file, "file", "", "指定审计文件路径(默认最新文件)")
return cmd
}
func auditDir() string {
if dir := os.Getenv(audit.EnvAuditDir); dir != "" {
return dir
}
return filepath.Join(defaultConfigDir(), "audit")
}
func tailFile(path string, n int) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var lines []string
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
return nil, err
}
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return lines, nil
}
func exportJSONL(files []string) error {
for _, file := range files {
f, err := os.Open(file)
if err != nil {
return err
}
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
fmt.Println(scanner.Text())
}
f.Close()
if err := scanner.Err(); err != nil {
return err
}
}
return nil
}
func exportCSV(files []string) error {
w := csv.NewWriter(os.Stdout)
header := []string{"timestamp", "execution_id", "user_id", "corp_id", "product", "command", "result", "duration_ms", "error_category"}
if err := w.Write(header); err != nil {
return fmt.Errorf("写入 CSV 表头失败: %w", err)
}
for _, file := range files {
f, err := os.Open(file)
if err != nil {
return err
}
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Bytes()
if len(bytes.TrimSpace(line)) == 0 {
continue
}
var evt audit.Event
if err := json.Unmarshal(line, &evt); err != nil {
f.Close()
return fmt.Errorf("解析审计记录失败 %s:%d: %w", file, lineNum, err)
}
row := []string{
evt.Timestamp.Format(time.RFC3339),
evt.ExecutionID,
evt.Actor.UserID,
evt.Actor.CorpID,
evt.Product,
evt.Command,
evt.Result,
strconv.FormatInt(evt.DurationMs, 10),
evt.ErrCategory,
}
if err := w.Write(row); err != nil {
f.Close()
return fmt.Errorf("写入 CSV 记录失败: %w", err)
}
}
if err := scanner.Err(); err != nil {
f.Close()
return err
}
f.Close()
}
w.Flush()
if err := w.Error(); err != nil {
return fmt.Errorf("刷新 CSV 输出失败: %w", err)
}
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestAuditTailRejectsNonPositiveLines(t *testing.T) {
for _, n := range []string{"0", "-1"} {
cmd := newAuditTailCommand()
cmd.SetArgs([]string{"--lines", n})
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
if err == nil {
t.Fatalf("--lines %s: expected error, got nil", n)
}
if !strings.Contains(err.Error(), "正整数") {
t.Fatalf("--lines %s: unexpected error: %v", n, err)
}
}
}
func TestTailFileReturnsLastN(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit-20260101.jsonl")
if err := os.WriteFile(path, []byte("a\nb\nc\nd\ne\n"), 0o600); err != nil {
t.Fatal(err)
}
lines, err := tailFile(path, 2)
if err != nil {
t.Fatal(err)
}
if len(lines) != 2 || lines[0] != "d" || lines[1] != "e" {
t.Fatalf("got %v, want [d e]", lines)
}
}
func TestExportCSVWritesHeaderAndRows(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit-20260101.jsonl")
rec := `{"timestamp":"2026-01-01T00:00:00Z","execution_id":"e1","actor":{"user_id":"u1","corp_id":"c1"},"product":"calendar","command":"event_list","result":"success","duration_ms":12,"hash":"h","prev_hash":""}`
if err := os.WriteFile(path, []byte(rec+"\n"), 0o600); err != nil {
t.Fatal(err)
}
stdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
exportErr := exportCSV([]string{path})
w.Close()
os.Stdout = stdout
if exportErr != nil {
t.Fatalf("exportCSV error: %v", exportErr)
}
buf := make([]byte, 4096)
n, _ := r.Read(buf)
out := string(buf[:n])
if !strings.Contains(out, "timestamp,execution_id") {
t.Fatalf("missing CSV header, got: %q", out)
}
if !strings.Contains(out, "e1") || !strings.Contains(out, "event_list") {
t.Fatalf("missing CSV row data, got: %q", out)
}
}
// TestExportCSVFailsOnMalformedJSON guards the reviewer's V9 finding: a corrupt
// JSONL line must surface an error with file/line evidence instead of being
// silently skipped while the command exits 0.
func TestExportCSVFailsOnMalformedJSON(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "audit-20260101.jsonl")
good := `{"timestamp":"2026-01-01T00:00:00Z","execution_id":"e1","actor":{"user_id":"u1"},"product":"calendar","command":"event_list","result":"success","duration_ms":1,"hash":"h","prev_hash":""}`
if err := os.WriteFile(path, []byte(good+"\nnot-json\n"), 0o600); err != nil {
t.Fatal(err)
}
stdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
exportErr := exportCSV([]string{path})
w.Close()
os.Stdout = stdout
// Drain the pipe so the writer never blocks.
buf := make([]byte, 4096)
_, _ = r.Read(buf)
if exportErr == nil {
t.Fatal("expected error on malformed JSONL, got nil")
}
if !strings.Contains(exportErr.Error(), "解析审计记录失败") {
t.Fatalf("error missing parse context: %v", exportErr)
}
if !strings.Contains(exportErr.Error(), ":2") {
t.Fatalf("error missing line evidence: %v", exportErr)
}
}
+164
View File
@@ -0,0 +1,164 @@
package app
import (
"errors"
"fmt"
"os"
"runtime"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
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/logging"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
var (
auditSinkOnce sync.Once
auditCloseOnce sync.Once
sharedAuditSink audit.Sink
auditIDMu sync.Mutex
cachedActor audit.Actor
cachedAgentID string
cachedProfile string
identityLoaded bool
// loadTokenForProfile is the profile-scoped token loader. It is a package
// variable so profile-switch Actor attribution can be tested deterministically
// without touching the OS keychain.
loadTokenForProfile = auth.LoadTokenDataForProfile
)
// setupAuditSink builds the process-wide audit sink once and caches it so the
// runner and the shutdown hook share a single writer/forwarder instance.
func setupAuditSink() audit.Sink {
auditSinkOnce.Do(func() {
sink, err := audit.BuildSink(defaultConfigDir(), auditReport)
if err != nil {
auditReport("initialization failed, audit disabled for this session: %v", err)
sharedAuditSink = audit.NopSink{}
return
}
sharedAuditSink = sink
})
return sharedAuditSink
}
// CloseAuditSink flushes in-flight remote forwards and closes the audit writer.
// It is invoked from an unconditional defer in Execute so the drain happens for
// both successful and failed commands (Cobra skips PersistentPostRunE when RunE
// returns an error). The sync.Once makes repeated calls safe.
func CloseAuditSink() {
auditCloseOnce.Do(func() {
if sharedAuditSink == nil {
return
}
if err := sharedAuditSink.Close(); err != nil {
auditReport("close failed: %v", err)
}
})
}
// auditReport routes non-fatal audit-subsystem diagnostics to the structured
// file log (always, when available) and to stderr when DWS_AUDIT_DEBUG is set,
// so init/write/forward failures are observable instead of silently swallowed.
func auditReport(format string, args ...any) {
msg := "audit: " + fmt.Sprintf(format, args...)
if l := FileLoggerInstance(); l != nil {
l.Warn(msg)
}
if audit.DebugEnabled() {
fmt.Fprintln(os.Stderr, "[dws] "+msg)
}
}
// auditIdentity resolves the Actor for the active runtime profile. The result
// is cached per-profile so a profile switch within a long-running process (e.g.
// serve mode) re-resolves rather than reusing a stale identity.
func auditIdentity() (audit.Actor, string) {
profile := auth.RuntimeProfile()
auditIDMu.Lock()
defer auditIDMu.Unlock()
if identityLoaded && profile == cachedProfile {
return cachedActor, cachedAgentID
}
configDir := defaultConfigDir()
var actor audit.Actor
if td, err := loadTokenForProfile(configDir, profile); err == nil && td != nil {
actor = audit.Actor{
UserID: td.UserID,
Name: td.UserName,
CorpID: td.CorpID,
CorpName: td.CorpName,
}
} else if err != nil {
auditReport("resolve actor for profile %q failed: %v", profile, err)
}
agentID := ""
if id := auth.Load(configDir); id != nil {
agentID = id.AgentID
}
cachedActor, cachedAgentID, cachedProfile, identityLoaded = actor, agentID, profile, true
return actor, agentID
}
func emitAudit(sink audit.Sink, execID string, invokeStart time.Time, invocation executor.Invocation, endpoint string, retErr error, cliVersion string) {
if sink == nil {
return
}
if _, ok := sink.(audit.NopSink); ok {
return
}
actor, agentID := auditIdentity()
result := "success"
var errCat, errReason string
if retErr != nil {
result = "error"
errCat, errReason = classifyAuditError(retErr)
}
paramsSummary := logging.SanitizeArguments(invocation.Params, 1024)
evt := &audit.Event{
Timestamp: invokeStart,
ExecutionID: execID,
AgentID: agentID,
Actor: actor,
Product: invocation.CanonicalProduct,
Command: invocation.Tool,
Endpoint: transport.RedactURL(endpoint),
ParamsSummary: paramsSummary,
Result: result,
ErrCategory: errCat,
ErrReason: errReason,
DurationMs: time.Since(invokeStart).Milliseconds(),
CLIVersion: cliVersion,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
}
if err := sink.Emit(evt); err != nil {
auditReport("emit event failed (exec %s): %v", execID, err)
}
}
func classifyAuditError(err error) (category, reason string) {
if err == nil {
return "", ""
}
var typed *apperrors.Error
if errors.As(err, &typed) {
return string(typed.Category), typed.Reason
}
return "unknown", err.Error()
}
+131
View File
@@ -0,0 +1,131 @@
package app
import (
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
// TestAuditIdentityReresolvesOnProfileSwitch guards the reviewer's finding that a
// long-running process (e.g. serve mode) must attribute events to the ACTIVE
// runtime profile rather than reusing a process-global first Actor. It also
// asserts the per-profile cache avoids redundant token loads within one profile.
func TestAuditIdentityReresolvesOnProfileSwitch(t *testing.T) {
prevLoader := loadTokenForProfile
prevProfile := auth.RuntimeProfile()
t.Cleanup(func() {
loadTokenForProfile = prevLoader
auth.SetRuntimeProfile(prevProfile)
resetAuditIdentityCache()
})
resetAuditIdentityCache()
var mu sync.Mutex
calls := map[string]int{}
loadTokenForProfile = func(_ /*configDir*/, profile string) (*auth.TokenData, error) {
mu.Lock()
calls[profile]++
mu.Unlock()
switch profile {
case "orgA":
return &auth.TokenData{UserID: "ua", UserName: "Alice", CorpID: "ca", CorpName: "CorpA"}, nil
case "orgB":
return &auth.TokenData{UserID: "ub", UserName: "Bob", CorpID: "cb", CorpName: "CorpB"}, nil
default:
return nil, nil
}
}
auth.SetRuntimeProfile("orgA")
if actor, _ := auditIdentity(); actor.UserID != "ua" || actor.CorpName != "CorpA" {
t.Fatalf("orgA: got %+v, want Alice/CorpA", actor)
}
// Second call under the same profile must hit the cache (no extra load).
if actor, _ := auditIdentity(); actor.UserID != "ua" {
t.Fatalf("orgA cached: got %+v", actor)
}
auth.SetRuntimeProfile("orgB")
if actor, _ := auditIdentity(); actor.UserID != "ub" || actor.CorpName != "CorpB" {
t.Fatalf("orgB: got %+v, want Bob/CorpB (stale Actor reused?)", actor)
}
mu.Lock()
defer mu.Unlock()
if calls["orgA"] != 1 {
t.Fatalf("orgA loaded %d times, want 1 (cache miss?)", calls["orgA"])
}
if calls["orgB"] != 1 {
t.Fatalf("orgB loaded %d times, want 1", calls["orgB"])
}
}
func resetAuditIdentityCache() {
auditIDMu.Lock()
defer auditIDMu.Unlock()
cachedActor = audit.Actor{}
cachedAgentID = ""
cachedProfile = ""
identityLoaded = false
}
// TestCloseAuditSinkDrainsOnErrorPath guards the reviewer's V5 finding: when a
// command's RunE returns an error, Cobra skips PersistentPostRunE, so the audit
// drain must instead happen through the unconditional defer in Execute that calls
// CloseAuditSink. This test wires a real forwarder-backed sink into the shared
// slot and asserts CloseAuditSink flushes the queued forward exactly as the
// error-path defer would, and that a second call is a harmless no-op.
func TestCloseAuditSinkDrainsOnErrorPath(t *testing.T) {
var delivered int64
var releaseOnce sync.Once
release := make(chan struct{})
releaseFn := func() { releaseOnce.Do(func() { close(release) }) }
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
<-release // hold the request until the drain awaits it
atomic.AddInt64(&delivered, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
defer releaseFn() // LIFO: unblock any in-flight handler before srv.Close()
writer, err := audit.NewDateRotatingWriter(t.TempDir(), 0)
if err != nil {
t.Fatal(err)
}
fwd := audit.NewHTTPForwarder(srv.URL, "", audit.RedactNone, nil)
sink := audit.NewFileSink(writer, audit.NewChain(""), fwd)
prevSink := sharedAuditSink
t.Cleanup(func() {
sharedAuditSink = prevSink
auditCloseOnce = sync.Once{}
})
sharedAuditSink = sink
auditCloseOnce = sync.Once{}
if err := sink.Emit(&audit.Event{Timestamp: time.Unix(0, 0), Product: "calendar", Command: "event_list", Result: "error"}); err != nil {
t.Fatalf("emit: %v", err)
}
if got := atomic.LoadInt64(&delivered); got != 0 {
t.Fatalf("forward delivered before drain: %d", got)
}
// Let the held request complete, then drain via the same entry point the
// error-path defer uses. CloseAuditSink blocks until the forward goroutine
// observes the HTTP response, so the counter is settled when it returns.
releaseFn()
CloseAuditSink()
if got := atomic.LoadInt64(&delivered); got != 1 {
t.Fatalf("forward not drained on error path: delivered=%d, want 1", got)
}
// Idempotent: the success-path PersistentPostRunE and the defer both call it.
CloseAuditSink()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-213
View File
@@ -1,213 +0,0 @@
// 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 (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
func TestPluginAuthRegistry(t *testing.T) {
// Clean up after test
defer func() {
pluginAuthMu.Lock()
delete(pluginAuthRegistry, "test-product")
pluginAuthMu.Unlock()
}()
// Initially not found
if _, ok := LookupPluginAuth("test-product"); ok {
t.Error("expected LookupPluginAuth to return false for unregistered product")
}
// Register auth credentials
auth := &PluginAuth{
Token: "sk-test-token-12345",
ExtraHeaders: map[string]string{"X-Custom": "value"},
TrustedDomains: []string{"api.example.com", "*.example.com"},
}
RegisterPluginAuth("test-product", auth)
// Now should be found
got, ok := LookupPluginAuth("test-product")
if !ok {
t.Fatal("expected LookupPluginAuth to return true after registration")
}
if got != auth {
t.Error("LookupPluginAuth returned different auth instance")
}
if got.Token != "sk-test-token-12345" {
t.Errorf("Token = %q, want sk-test-token-12345", got.Token)
}
if got.ExtraHeaders["X-Custom"] != "value" {
t.Errorf("ExtraHeaders[X-Custom] = %q, want value", got.ExtraHeaders["X-Custom"])
}
if len(got.TrustedDomains) != 2 {
t.Errorf("TrustedDomains len = %d, want 2", len(got.TrustedDomains))
}
}
func TestPluginAuthRegistryIsolation(t *testing.T) {
// Clean up after test
defer func() {
pluginAuthMu.Lock()
delete(pluginAuthRegistry, "product-a")
delete(pluginAuthRegistry, "product-b")
pluginAuthMu.Unlock()
}()
authA := &PluginAuth{Token: "token-a"}
authB := &PluginAuth{Token: "token-b"}
RegisterPluginAuth("product-a", authA)
RegisterPluginAuth("product-b", authB)
gotA, okA := LookupPluginAuth("product-a")
gotB, okB := LookupPluginAuth("product-b")
if !okA || !okB {
t.Fatal("expected both products to be registered")
}
if gotA.Token != "token-a" {
t.Errorf("product-a Token = %q, want token-a", gotA.Token)
}
if gotB.Token != "token-b" {
t.Errorf("product-b Token = %q, want token-b", gotB.Token)
}
}
func TestDeriveToolCLIName(t *testing.T) {
tests := []struct {
input string
want string
}{
{"web_search", "web-search"},
{"maps.search_poi", "search-poi"},
{"maps.geo", "geo"},
{"simple", "simple"},
{"a.b.deep_nested_name", "deep-nested-name"},
{"already-kebab", "already-kebab"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := deriveToolCLIName(tt.input)
if got != tt.want {
t.Errorf("deriveToolCLIName(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestRegisterPluginAuthFromHeaders(t *testing.T) {
// Clean up after test
defer func() {
pluginAuthMu.Lock()
delete(pluginAuthRegistry, "test-srv")
pluginAuthMu.Unlock()
}()
srv := market.ServerDescriptor{
Key: "test-srv",
Endpoint: "https://api.example.com/mcp/v1",
CLI: market.CLIOverlay{ID: "test-srv", Command: "test-srv"},
AuthHeaders: map[string]string{
"Authorization": "Bearer sk-my-secret-key",
"X-Custom": "custom-value",
},
}
registerPluginAuthFromHeaders(srv)
auth, ok := LookupPluginAuth("test-srv")
if !ok {
t.Fatal("expected auth to be registered after registerPluginAuthFromHeaders")
}
if auth.Token != "sk-my-secret-key" {
t.Errorf("Token = %q, want sk-my-secret-key", auth.Token)
}
if auth.ExtraHeaders["X-Custom"] != "custom-value" {
t.Errorf("ExtraHeaders[X-Custom] = %q, want custom-value", auth.ExtraHeaders["X-Custom"])
}
if len(auth.TrustedDomains) != 2 {
t.Fatalf("TrustedDomains len = %d, want 2", len(auth.TrustedDomains))
}
if auth.TrustedDomains[0] != "api.example.com" {
t.Errorf("TrustedDomains[0] = %q, want api.example.com", auth.TrustedDomains[0])
}
}
func TestRegisterPluginAuthFromHeadersNoAuth(t *testing.T) {
srv := market.ServerDescriptor{
Key: "no-auth-srv",
Endpoint: "https://api.example.com/mcp/v1",
CLI: market.CLIOverlay{ID: "no-auth-srv"},
AuthHeaders: map[string]string{
"X-Custom": "custom-value",
},
}
registerPluginAuthFromHeaders(srv)
// Should not register because there's no Authorization header
if _, ok := LookupPluginAuth("no-auth-srv"); ok {
t.Error("expected no auth registration when Authorization header is missing")
}
}
func TestBuildPluginAuthClient(t *testing.T) {
base := transport.NewClient(nil)
srv := market.ServerDescriptor{
Endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1/mcp",
AuthHeaders: map[string]string{
"Authorization": "Bearer sk-test-api-key",
"X-Extra": "extra-value",
},
}
client := buildPluginAuthClient(base, srv)
// Should return a different client instance
if client == base {
t.Error("expected buildPluginAuthClient to return a new client, not the base")
}
// Verify trusted domains
if len(client.TrustedDomains) != 2 {
t.Fatalf("TrustedDomains len = %d, want 2", len(client.TrustedDomains))
}
if client.TrustedDomains[0] != "dashscope.aliyuncs.com" {
t.Errorf("TrustedDomains[0] = %q, want dashscope.aliyuncs.com", client.TrustedDomains[0])
}
}
func TestBuildPluginAuthClientNoAuth(t *testing.T) {
base := transport.NewClient(nil)
srv := market.ServerDescriptor{
Endpoint: "https://api.example.com/mcp/v1",
AuthHeaders: map[string]string{
"X-Custom": "custom-value",
},
}
client := buildPluginAuthClient(base, srv)
// Should return the base client when no Authorization header
if client != base {
t.Error("expected buildPluginAuthClient to return base client when no Authorization header")
}
}
+78
View File
@@ -0,0 +1,78 @@
// 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 (
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
)
type cacheCompatNotice struct {
Status string `json:"status"`
Command string `json:"command"`
Message string `json:"message"`
Replacement string `json:"replacement,omitempty"`
}
func newCacheCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cache",
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
Hidden: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
for _, name := range []string{"refresh", "status", "clean"} {
sub := &cobra.Command{
Use: name,
Short: "已弃用:静态端点模式无需服务发现缓存",
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return printCacheCompatNotice(cmd, name)
},
}
cmd.AddCommand(sub)
}
return cmd
}
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
notice := cacheCompatNotice{
Status: "deprecated",
Command: "dws cache " + command,
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
switch strings.ToLower(strings.TrimSpace(format)) {
case "", "json":
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
case "pretty":
data, err := json.MarshalIndent(notice, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
return err
default:
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
return err
}
}
+19
View File
@@ -0,0 +1,19 @@
package app
import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
)
func newCatalogCommand(_ cli.CatalogLoader) *cobra.Command {
return &cobra.Command{
Use: "catalog",
Short: "查看服务目录 (静态端点模式)",
Hidden: true,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
}
+114 -31
View File
@@ -23,7 +23,8 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"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/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
)
var (
@@ -45,14 +46,23 @@ const (
defaultPATProductID = "pat"
defaultPATDisplayName = "行为授权"
defaultPATServerID = "abc3c880fb90f04b52d1426aaf093766e5fc9ec38411688cbb74df42a584d374"
devappProductID = "devapp"
devappServerPath = "/server/op-app"
)
func defaultPATServerDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
// devappMCPEndpoint resolves the open-platform app-management MCP endpoint
// from the configured gateway base URL, so it follows the active environment
// (production by default, pre when ~/.dws/mcp_url points at the pre gateway).
func devappMCPEndpoint() string {
return defaultPATGatewayBaseURL() + devappServerPath
}
func defaultPATServerDescriptor() mcptypes.ServerDescriptor {
return mcptypes.ServerDescriptor{
Key: defaultPATProductID,
DisplayName: defaultPATDisplayName,
Endpoint: defaultPATMCPEndpoint(),
CLI: market.CLIOverlay{
CLI: mcptypes.CLIOverlay{
ID: defaultPATProductID,
Command: defaultPATProductID,
Prefixes: []string{defaultPATProductID},
@@ -94,7 +104,7 @@ func defaultPATGatewayBaseURL() string {
// SetDynamicServers injects server data discovered from servers.json.
// All product endpoints are resolved dynamically from this data.
func SetDynamicServers(servers []market.ServerDescriptor) {
func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
dynamicMu.Lock()
defer dynamicMu.Unlock()
@@ -157,7 +167,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) {
dynamicToolEndpoints = toolEndpoints
}
func registerDynamicServer(server market.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
if server.CLI.Skip {
return
}
@@ -210,6 +220,22 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
}
}
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
// whose toolOverrides registered this tool name. Used to correct catalog
// lookups when two envelope servers share the same cli.command and the
// per-product endpoint map collides (see runner.go cross-check).
func directRuntimeToolEndpoint(toolName string) (string, bool) {
toolName = strings.TrimSpace(toolName)
if toolName == "" {
return "", false
}
dynamicMu.RLock()
defer dynamicMu.RUnlock()
endpoint, ok := dynamicToolEndpoints[toolName]
return endpoint, ok && strings.TrimSpace(endpoint) != ""
}
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
normalized := normalizeDirectRuntimeProductID(productID)
@@ -222,24 +248,27 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
}
}
dynamicMu.RLock()
de := dynamicEndpoints
te := dynamicToolEndpoints
dynamicMu.RUnlock()
// Hardcoded built-in: devapp is pinned to the open-platform app-management
// MCP server in source (NOT service discovery), per product decision.
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == devappProductID {
return devappMCPEndpoint(), true
}
}
// 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.
dynamicMu.RLock()
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if candidate == "" {
continue
}
if de != nil {
if endpoint, ok := de[candidate]; ok {
return endpoint, true
}
if endpoint, ok := dynamicEndpoints[candidate]; ok {
dynamicMu.RUnlock()
return endpoint, true
}
}
@@ -247,11 +276,13 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
// 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 {
if tool := strings.TrimSpace(toolName); tool != "" {
if endpoint, ok := dynamicToolEndpoints[tool]; ok {
dynamicMu.RUnlock()
return endpoint, true
}
}
dynamicMu.RUnlock()
// Priority 3: built-in PAT fallback for cold-start paths that run before
// discovery/plugin registration has populated the dynamic registry.
@@ -260,18 +291,69 @@ func directRuntimeEndpoint(productID, toolName string) (string, bool) {
return defaultPATMCPEndpoint(), true
}
}
// Priority 4: edition-owned static/supplement endpoints. Helper-only
// products such as devapp intentionally do not depend on Market discovery,
// so the internal edition may provide only an endpoint and no tool list.
for _, candidate := range []string{strings.TrimSpace(productID), normalized} {
if endpoint, ok := editionServerEndpoint(candidate); ok {
return endpoint, true
}
}
return "", false
}
// DirectRuntimeProductIDs returns the set of product IDs that have direct
// runtime endpoints configured, sourced from dynamic server discovery.
func editionServerEndpoint(productID string) (string, bool) {
productID = strings.TrimSpace(productID)
if productID == "" {
return "", false
}
hooks := edition.Get()
if hooks == nil {
return "", false
}
if endpoint, ok := endpointFromEditionServers(productID, hooks.StaticServers); ok {
return endpoint, true
}
if endpoint, ok := endpointFromEditionServers(productID, hooks.SupplementServers); ok {
return endpoint, true
}
return "", false
}
func endpointFromEditionServers(productID string, fn func() []edition.ServerInfo) (string, bool) {
if fn == nil {
return "", false
}
for _, server := range fn() {
endpoint := strings.TrimSpace(server.Endpoint)
if endpoint == "" {
continue
}
if strings.TrimSpace(server.ID) == productID {
return endpoint, true
}
for _, prefix := range server.Prefixes {
if strings.TrimSpace(prefix) == productID {
return endpoint, true
}
}
}
return "", false
}
// DirectRuntimeProductIDs returns product IDs that should stay visible for
// direct runtime execution. Dynamic products come from MCP discovery/plugin
// registration; built-in helper products such as devapp resolve their endpoint
// through DINGTALK_<PRODUCT>_MCP_URL instead of requiring discovery.
func DirectRuntimeProductIDs() map[string]bool {
dynamicMu.RLock()
dp := dynamicProducts
dynamicMu.RUnlock()
ids := make(map[string]bool, len(dp)+1)
defer dynamicMu.RUnlock()
ids := make(map[string]bool, len(dynamicProducts)+2)
ids[defaultPATProductID] = true
for key := range dp {
ids[devappProductID] = true
for key := range dynamicProducts {
ids[key] = true
}
return ids
@@ -281,7 +363,7 @@ func DirectRuntimeProductIDs() map[string]bool {
// dynamic server registry without replacing the current entries. This
// is used by the plugin loader to inject plugin servers alongside
// Market-discovered servers.
func AppendDynamicServer(server market.ServerDescriptor) {
func AppendDynamicServer(server mcptypes.ServerDescriptor) {
dynamicMu.Lock()
defer dynamicMu.Unlock()
@@ -310,7 +392,9 @@ func AppendDynamicServer(server market.ServerDescriptor) {
}
cmd := strings.TrimSpace(server.CLI.Command)
if cmd != "" && cmd != id && endpoint != "" {
dynamicEndpoints[cmd] = endpoint
if _, exists := dynamicEndpoints[cmd]; !exists {
dynamicEndpoints[cmd] = endpoint
}
dynamicProducts[cmd] = true
}
for _, alias := range server.CLI.Aliases {
@@ -344,15 +428,14 @@ func AppendDynamicServer(server market.ServerDescriptor) {
}
func normalizeDirectRuntimeProductID(productID string) string {
dynamicMu.RLock()
da := dynamicAliases
dynamicMu.RUnlock()
trimmed := strings.TrimSpace(productID)
if da != nil {
if normalizedID, ok := da[trimmed]; ok && normalizedID != "" {
return normalizedID
}
dynamicMu.RLock()
if normalizedID, ok := dynamicAliases[trimmed]; ok && normalizedID != "" {
dynamicMu.RUnlock()
return normalizedID
}
dynamicMu.RUnlock()
if normalizedID, ok := legacyDirectRuntimeAliases[trimmed]; ok {
return normalizedID
}
@@ -1,294 +0,0 @@
// 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 (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
)
// Regression for the chat/bot tool routing bug: when the `chat` envelope
// declares toolOverrides with `serverOverride: "bot"` (e.g. `search_my_robots`,
// `send_message_by_custom_robot`), those tool names must NOT be registered
// into `dynamicToolEndpoints` pointing at chat's endpoint. Otherwise the
// tool-level Priority 1 lookup in `directRuntimeEndpoint` returns chat's URL
// even when the invocation's CanonicalProduct is "bot", causing the Portal to
// respond with `PARAM_ERROR - 未找到指定工具` because chat's mcpId has no such
// tool.
//
// Owner (bot envelope) still registers the tool (no serverOverride on the bot
// side), so product-level and tool-level lookups both resolve correctly.
const (
testBotEndpoint = "https://pre-mcp-gw.dingtalk.com/server/4717d5cbb92ecdebd89c174e4331dc17207208a97622e2004cac49c0fbedc9d1"
testChatEndpoint = "https://pre-mcp-gw.dingtalk.com/server/0a1609437385696b77fc4771c3ddaf5656b487f809966c0cc8d4755e7b1d3b74"
)
// botDescriptor returns a minimal `bot` server descriptor that owns the
// `search_my_robots` + `send_message_by_custom_robot` tools (no
// serverOverride — bot is the real owner).
func botDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testBotEndpoint,
CLI: market.CLIOverlay{
ID: "bot",
ToolOverrides: map[string]market.CLIToolOverride{
"search_my_robots": {CLIName: "search"},
"send_message_by_custom_robot": {CLIName: "send-by-webhook"},
"add_robot_to_group": {CLIName: "add-bot"},
},
},
}
}
// chatDescriptor returns a minimal `chat` server descriptor whose
// toolOverrides include bot-owned tools via `serverOverride: "bot"`, plus a
// chat-native tool (`search_groups_by_keyword`) that must remain routed to
// chat's endpoint.
func chatDescriptor() market.ServerDescriptor {
return market.ServerDescriptor{
Endpoint: testChatEndpoint,
CLI: market.CLIOverlay{
ID: "chat",
Command: "chat",
ToolOverrides: map[string]market.CLIToolOverride{
"search_groups_by_keyword": {CLIName: "search"},
"search_my_robots": {
CLIName: "search",
ServerOverride: "bot",
},
"send_message_by_custom_robot": {
CLIName: "send-by-webhook",
ServerOverride: "bot",
},
"add_robot_to_group": {
CLIName: "add-bot",
ServerOverride: "bot",
},
},
},
}
}
// withCleanDynamicRegistry snapshots and restores the package-level dynamic
// registries so parallel/other tests aren't affected by this case's mutations.
func withCleanDynamicRegistry(t *testing.T) {
t.Helper()
dynamicMu.Lock()
prev := struct {
endpoints map[string]string
products map[string]bool
aliases map[string]string
toolEndpoints map[string]string
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
dynamicEndpoints = nil
dynamicProducts = nil
dynamicAliases = nil
dynamicToolEndpoints = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = prev.endpoints
dynamicProducts = prev.products
dynamicAliases = prev.aliases
dynamicToolEndpoints = prev.toolEndpoints
dynamicMu.Unlock()
})
}
func assertEndpoint(t *testing.T, productID, toolName, want string) {
t.Helper()
got, ok := directRuntimeEndpoint(productID, toolName)
if !ok {
t.Fatalf("directRuntimeEndpoint(%q, %q) returned ok=false", productID, toolName)
}
if got != want {
t.Fatalf("directRuntimeEndpoint(%q, %q) = %q, want %q", productID, toolName, got, want)
}
}
// TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint verifies that
// chat's serverOverride entries cannot steal bot-owned tool routes, regardless
// of registration order.
func TestSetDynamicServers_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
tests := []struct {
name string
servers []market.ServerDescriptor
}{
{
name: "bot first, chat second",
servers: []market.ServerDescriptor{botDescriptor(), chatDescriptor()},
},
{
name: "chat first, bot second",
servers: []market.ServerDescriptor{chatDescriptor(), botDescriptor()},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
withCleanDynamicRegistry(t)
SetDynamicServers(tc.servers)
// Bot-owned tools must route to bot's endpoint even though chat
// declares toolOverrides for them (with serverOverride="bot").
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
assertEndpoint(t, "bot", "add_robot_to_group", testBotEndpoint)
// Chat-native tools must still route to chat.
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
// Product-level fallback for bot (no tool name) must also return
// bot's endpoint.
assertEndpoint(t, "bot", "", testBotEndpoint)
})
}
}
// TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint exercises
// the plugin-injection path (`AppendDynamicServer`) which has the same
// `toolOverrides` registration loop as `SetDynamicServers`. Chat's
// serverOverride entries must not overwrite bot's tool → endpoint mapping.
func TestAppendDynamicServer_ServerOverrideDoesNotHijackToolEndpoint(t *testing.T) {
orders := [][]market.ServerDescriptor{
{botDescriptor(), chatDescriptor()},
{chatDescriptor(), botDescriptor()},
}
for _, servers := range orders {
t.Run("", func(t *testing.T) {
withCleanDynamicRegistry(t)
for _, s := range servers {
AppendDynamicServer(s)
}
assertEndpoint(t, "bot", "search_my_robots", testBotEndpoint)
assertEndpoint(t, "bot", "send_message_by_custom_robot", testBotEndpoint)
assertEndpoint(t, "chat", "search_groups_by_keyword", testChatEndpoint)
})
}
}
// --- 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)
}
-96
View File
@@ -1,96 +0,0 @@
package app
import (
"os"
"path/filepath"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
)
func TestDefaultPATServerDescriptorUsesBehaviorAuthorizationName(t *testing.T) {
server := defaultPATServerDescriptor()
if server.CLI.ID != "pat" {
t.Fatalf("default PAT server id = %q, want pat", server.CLI.ID)
}
if server.DisplayName != "行为授权" {
t.Fatalf("default PAT server display name = %q, want 行为授权", server.DisplayName)
}
if server.Endpoint != defaultPATMCPEndpoint() {
t.Fatalf("default PAT server endpoint = %q, want %q", server.Endpoint, defaultPATMCPEndpoint())
}
}
func TestDirectRuntimeProductIDsIncludesDefaultPAT(t *testing.T) {
dynamicMu.Lock()
previousProducts := dynamicProducts
dynamicProducts = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicProducts = previousProducts
dynamicMu.Unlock()
})
ids := DirectRuntimeProductIDs()
if !ids["pat"] {
t.Fatalf("DirectRuntimeProductIDs() missing default pat product: %#v", ids)
}
}
func TestDirectRuntimeEndpoint_DefaultPATFallbackWhenRegistryMissing(t *testing.T) {
withCleanDynamicRegistry(t)
assertEndpoint(t, "pat", "", defaultPATMCPEndpoint())
}
func TestDirectRuntimeEndpoint_DefaultPATFallbackUsesConfiguredMCPBaseURL(t *testing.T) {
withCleanDynamicRegistry(t)
tmpDir := t.TempDir()
if err := os.WriteFile(filepath.Join(tmpDir, "mcp_url"), []byte("http://127.0.0.1:54321/base"), 0o600); err != nil {
t.Fatalf("WriteFile(mcp_url) error = %v", err)
}
t.Setenv("DWS_CONFIG_DIR", tmpDir)
assertEndpoint(t, "pat", "", "http://127.0.0.1:54321/base/server/"+defaultPATServerID)
}
func TestDirectRuntimeEndpoint_PATDiscoveryOverrideWinsOverBuiltInFallback(t *testing.T) {
withCleanDynamicRegistry(t)
customEndpoint := "https://example.com/server/custom-pat"
SetDynamicServers([]market.ServerDescriptor{
{
Endpoint: customEndpoint,
CLI: market.CLIOverlay{
ID: "pat",
Command: "pat",
},
},
})
assertEndpoint(t, "pat", "", customEndpoint)
}
func TestNormalizeDirectRuntimeProductIDPreservesLegacyHiddenVendorRouting(t *testing.T) {
dynamicMu.Lock()
previousAliases := dynamicAliases
dynamicAliases = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicAliases = previousAliases
dynamicMu.Unlock()
})
cases := map[string]string{
"tb": "teambition",
"dingtalk-discovery": "discovery",
"dingtalk-oa-plus": "oa",
"dingtalk-ai-sincere-hire": "ai-sincere-hire",
}
for input, want := range cases {
if got := normalizeDirectRuntimeProductID(input); got != want {
t.Fatalf("normalizeDirectRuntimeProductID(%q) = %q, want %q", input, got, want)
}
}
}
+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 ""
}
+93 -73
View File
@@ -21,16 +21,17 @@ import (
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
var doctorKeychainDiagnose = keychain.Diagnose
// checkStatus represents the outcome of a single doctor check.
type checkStatus string
@@ -78,6 +79,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
authResult := doctorCheckAuth(cmd.Context(), w, jsonOut)
checks = append(checks, authResult)
keychainResult := doctorCheckKeychain(w, jsonOut)
checks = append(checks, keychainResult)
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
checks = append(checks, networkResult)
@@ -113,7 +117,8 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
return output.WriteJSON(w, result)
}
fmt.Fprintf(w, "\n诊断完成: %d 项通过, %d 项警告, %d 项失败\n", pass, warn, fail)
fmt.Fprintf(w, "\n%s\n", tui.Header("Doctor", fmt.Sprintf("%d pass · %d warn · %d fail", pass, warn, fail)))
fmt.Fprintf(w, "%s 诊断完成: %d 项通过, %d 项警告, %d 项失败\n", tui.StateMark("ok"), pass, warn, fail)
if fail > 0 {
return fmt.Errorf("诊断发现 %d 项失败", fail)
}
@@ -124,7 +129,7 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, "检查登录状态... ")
fmt.Fprint(w, tui.Dim("检查登录状态... "))
}
configDir := defaultConfigDir()
@@ -133,6 +138,19 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
data, err := provider.Status()
if err != nil || data == nil {
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
r := checkResult{
Name: "auth",
Status: statusFail,
Message: diagnostic.Message,
Hint: diagnostic.Hint,
Detail: map[string]string{"reason": diagnostic.Reason},
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"}
if !edition.Get().IsEmbedded {
r.Hint = "运行 dws auth login 进行登录"
@@ -183,29 +201,60 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
return r
}
// ── Keychain check ─────────────────────────────────────────────────────
func doctorCheckKeychain(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, tui.Dim("检查钥匙串状态... "))
}
diagnostic := doctorKeychainDiagnose()
r := checkResult{
Name: "keychain",
Status: statusPass,
Message: diagnostic.Message,
Detail: diagnostic.Detail,
}
if !diagnostic.OK {
r.Status = statusFail
r.Hint = diagnostic.Hint
if diagnostic.Detail == nil {
r.Detail = map[string]string{"reason": diagnostic.Reason}
} else if diagnostic.Reason != "" {
detail := make(map[string]string, len(diagnostic.Detail)+1)
for k, v := range diagnostic.Detail {
detail[k] = v
}
detail["reason"] = diagnostic.Reason
r.Detail = detail
}
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
// ── Network check ───────────────────────────────────────────────────────
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
if !jsonOut {
fmt.Fprint(w, "检查网络连通性... ")
fmt.Fprint(w, tui.Dim("检查网络连通性... "))
}
baseURL := cli.DefaultMarketBaseURL
baseURL := config.GetMCPBaseURL()
httpClient := &http.Client{Timeout: timeout}
client := market.NewClient(baseURL, httpClient)
start := time.Now()
reqCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
_, err := client.FetchServers(reqCtx, 1)
latency := time.Since(start)
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, baseURL, nil)
if err != nil {
r := checkResult{
Name: "network",
Status: statusFail,
Message: fmt.Sprintf("mcp.dingtalk.com 不可达: %v", err),
Message: fmt.Sprintf("%s 不可达: %v", baseURL, err),
Hint: "请检查网络连接或代理设置",
}
if !jsonOut {
@@ -214,10 +263,26 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
return r
}
resp, err := httpClient.Do(req)
latency := time.Since(start)
if err != nil {
r := checkResult{
Name: "network",
Status: statusFail,
Message: fmt.Sprintf("%s 不可达: %v", baseURL, err),
Hint: "请检查网络连接或代理设置",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
resp.Body.Close()
r := checkResult{
Name: "network",
Status: statusPass,
Message: fmt.Sprintf("mcp.dingtalk.com 可达 (延迟 %dms)", latency.Milliseconds()),
Message: fmt.Sprintf("%s 可达 (延迟 %dms)", baseURL, latency.Milliseconds()),
}
if !jsonOut {
printCheckResult(w, r)
@@ -229,67 +294,13 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, "检查缓存状态... ")
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
}
store := cacheStoreFromEnv()
files, _, err := cacheDirectoryStats(store.Root)
if err != nil {
r := checkResult{
Name: "cache",
Status: statusFail,
Message: fmt.Sprintf("缓存目录不可读: %v", err),
Hint: "运行 dws cache clean 清理后重试",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
entries, _ := store.ListToolsCacheEntries(config.DefaultPartition)
if files == 0 && len(entries) == 0 {
r := checkResult{
Name: "cache",
Status: statusWarn,
Message: "缓存为空 (首次使用)",
Hint: "运行任意 dws 命令后将自动建立缓存",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
staleCount := 0
for _, e := range entries {
if e.Freshness == cache.FreshnessStale {
staleCount++
}
}
if staleCount > 0 {
r := checkResult{
Name: "cache",
Status: statusWarn,
Message: fmt.Sprintf("%d 个文件, %d 个工具缓存, %d 个已过期", files, len(entries), staleCount),
Hint: "运行 dws cache refresh 刷新缓存",
}
if !jsonOut {
printCheckResult(w, r)
}
return r
}
msg := fmt.Sprintf("%d 个文件, %d 个工具缓存", files, len(entries))
if len(entries) > 0 {
msg += ", 全部新鲜"
}
r := checkResult{
Name: "cache",
Status: statusPass,
Message: msg,
Message: "静态端点模式, 无需缓存",
}
if !jsonOut {
printCheckResult(w, r)
@@ -301,7 +312,7 @@ func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
if !jsonOut {
fmt.Fprint(w, "检查版本更新... ")
fmt.Fprint(w, tui.Dim("检查版本更新... "))
}
currentVer := version
@@ -349,9 +360,18 @@ func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkR
func printCheckResult(w io.Writer, r checkResult) {
icon := statusIcon(r.Status)
fmt.Fprintf(w, "%s %s\n", icon, r.Message)
message := r.Message
switch r.Status {
case statusPass:
message = tui.Success(message)
case statusWarn:
message = tui.Warning(message)
case statusFail:
message = tui.Danger(message)
}
fmt.Fprintf(w, "%s %s\n", icon, message)
if r.Hint != "" {
fmt.Fprintf(w, " %s\n", r.Hint)
fmt.Fprintf(w, " %s\n", tui.Dim(r.Hint))
}
}
@@ -386,7 +406,7 @@ func countResults(checks []checkResult) (pass, warn, fail int) {
func doctorCheckPerf(w io.Writer, jsonOut bool) checkResult {
if !jsonOut {
fmt.Fprint(w, "检查性能报告... ")
fmt.Fprint(w, tui.Dim("检查性能报告... "))
}
report, err := LoadLatestReport()
+113 -7
View File
@@ -15,9 +15,16 @@ package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCountResults(t *testing.T) {
@@ -108,11 +115,8 @@ func TestDoctorCheckCacheEmpty(t *testing.T) {
var buf bytes.Buffer
r := doctorCheckCache(&buf, false)
if r.Status != statusWarn {
t.Errorf("expected warn for empty cache, got %s", r.Status)
}
if !strings.Contains(r.Message, "缓存为空") {
t.Errorf("expected empty cache message, got %q", r.Message)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
}
@@ -122,14 +126,116 @@ func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
var buf bytes.Buffer
r := doctorCheckCache(&buf, true)
if r.Status != statusWarn {
t.Errorf("expected warn for empty cache, got %s", r.Status)
if r.Status != statusPass {
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
}
if buf.Len() != 0 {
t.Error("expected no output in JSON mode")
}
}
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
},
})
t.Cleanup(func() {
edition.Override(prev)
})
var buf bytes.Buffer
r := doctorCheckAuth(context.Background(), &buf, false)
if r.Name != "auth" {
t.Fatalf("name = %q, want auth", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if !strings.Contains(r.Message, "Keychain") && !strings.Contains(r.Message, "钥匙串") {
t.Fatalf("message should mention Keychain/钥匙串; result=%+v", r)
}
if !strings.Contains(r.Hint, keychain.DisableKeychainEnv) {
t.Fatalf("hint should mention %s; result=%+v", keychain.DisableKeychainEnv, r)
}
}
func TestDoctorCheckAuthReportsDEKMissing(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(configDir string) ([]byte, error) {
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
},
})
t.Cleanup(func() {
edition.Override(prev)
})
var buf bytes.Buffer
r := doctorCheckAuth(context.Background(), &buf, false)
if r.Name != "auth" {
t.Fatalf("name = %q, want auth", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if !strings.Contains(r.Message, "登录密钥") {
t.Fatalf("message should mention 登录密钥; result=%+v", r)
}
if !strings.Contains(r.Hint, "重新登录") {
t.Fatalf("hint should mention 重新登录; result=%+v", r)
}
detail, ok := r.Detail.(map[string]string)
if !ok || detail["reason"] != "dek_missing" {
t.Fatalf("detail = %#v, want reason=dek_missing", r.Detail)
}
}
func TestDoctorCheckKeychainReportsUnavailable(t *testing.T) {
prev := doctorKeychainDiagnose
doctorKeychainDiagnose = func() keychain.Diagnostic {
return keychain.Diagnostic{
OK: false,
Reason: "keychain_unavailable",
Message: "macOS 默认钥匙串不存在",
Hint: "恢复默认钥匙串后重试",
Detail: map[string]string{
"default_keychain": "/tmp/missing.keychain-db",
},
}
}
t.Cleanup(func() {
doctorKeychainDiagnose = prev
})
var buf bytes.Buffer
r := doctorCheckKeychain(&buf, false)
if r.Name != "keychain" {
t.Fatalf("name = %q, want keychain", r.Name)
}
if r.Status != statusFail {
t.Fatalf("status = %q, want fail", r.Status)
}
if r.Message != "macOS 默认钥匙串不存在" {
t.Fatalf("message = %q", r.Message)
}
if r.Hint == "" {
t.Fatalf("hint is empty; result=%+v", r)
}
detail, ok := r.Detail.(map[string]string)
if !ok || detail["default_keychain"] == "" {
t.Fatalf("detail = %#v, want default_keychain", r.Detail)
}
}
func TestDoctorCommandStructure(t *testing.T) {
cmd := newDoctorCommand()
if cmd.Use != "doctor" {
+66
View File
@@ -0,0 +1,66 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
runner := &countingErrorRunner{}
caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true, Format: "json"})
result, err := caller.CallTool(context.Background(), "aitable-helper", "set_advanced_permission", map[string]any{"enabled": false})
if err != nil {
t.Fatalf("CallTool() error = %v", err)
}
if got := runner.calls.Load(); got != 0 {
t.Fatalf("runner calls = %d, want 0", got)
}
if result == nil || len(result.Content) != 1 || !strings.Contains(result.Content[0].Text, `"dry_run":true`) {
t.Fatalf("dry-run result = %#v", result)
}
var nilAdapter *toolCallerAdapter
if nilAdapter.DryRun() || nilAdapter.Format() != "json" {
t.Fatal("nil adapter accessors are not safe")
}
if _, err := nilAdapter.CallTool(context.Background(), "x", "y", nil); err == nil {
t.Fatal("nil adapter accepted a tool call")
}
}
func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
fallback := &countingErrorRunner{}
runner := &runtimeRunner{globalFlags: &GlobalFlags{DryRun: true}, fallback: fallback}
result, err := runner.Run(context.Background(), executor.NewHelperInvocation(
"test",
"aitable",
"tool",
map[string]any{"id": "x"},
))
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if !result.Invocation.DryRun || result.Response["dry_run"] != true {
t.Fatalf("dry-run result = %#v", result)
}
if got := fallback.calls.Load(); got != 0 {
t.Fatalf("fallback calls = %d, want 0", got)
}
}
type countingErrorRunner struct {
calls atomic.Int64
}
func (r *countingErrorRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
r.calls.Add(1)
return executor.Result{}, errors.New("runner must not be called")
}
File diff suppressed because it is too large Load Diff
+69
View File
@@ -0,0 +1,69 @@
// 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 (
"encoding/json"
"errors"
"os"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
)
func writeEventTestAppConfig(t *testing.T, dir string, cfg authpkg.AppConfig) {
t.Helper()
raw, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
t.Fatalf("marshal app config: %v", err)
}
if err := os.WriteFile(authpkg.GetAppConfigPath(dir), raw, 0o600); err != nil {
t.Fatalf("write app config: %v", err)
}
}
func TestResolveEventCredentials_PortalNormalAllowsMissingClientSecret(t *testing.T) {
t.Setenv(authpkg.EnvClientID, "")
t.Setenv(authpkg.EnvClientSecret, "")
dir := t.TempDir()
clientID, clientSecret, err := resolveEventCredentials(dir, eventStreamTicketOptions{
Mode: source.PortalTicketModeNormal,
SourceID: "pre_open_source",
})
if err != nil {
t.Fatalf("resolveEventCredentials: %v", err)
}
if clientID != "portal-ticket-normal:pre_open_source" {
t.Fatalf("clientID = %q, want portal-ticket-normal:pre_open_source", clientID)
}
if clientSecret != "" {
t.Fatalf("clientSecret = %q, want empty", clientSecret)
}
}
func TestResolveEventCredentials_PortalCustomStillRequiresClientSecret(t *testing.T) {
t.Setenv(authpkg.EnvClientID, "")
t.Setenv(authpkg.EnvClientSecret, "")
dir := t.TempDir()
writeEventTestAppConfig(t, dir, authpkg.AppConfig{ClientID: "ding-custom"})
_, _, err := resolveEventCredentials(dir, eventStreamTicketOptions{
Mode: source.PortalTicketModeCustom,
})
if !errors.Is(err, authpkg.ErrClientSecretEmpty) {
t.Fatalf("err = %v, want ErrClientSecretEmpty", err)
}
}
+877
View File
@@ -0,0 +1,877 @@
// 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"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"text/tabwriter"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type commonConsumeOptions struct {
EventTypes []string
Filter string
Compact bool
FormatRaw string
OutputDir string
RoutesRaw []string
MaxEvents int
Duration time.Duration
Quiet bool
Force bool
DryRun bool
Foreground bool
}
type personalConsumeOptions struct {
Common commonConsumeOptions
EventKey string
DebugRawEvents bool
SubscribeID string
Rule string
Name string
FilterJSON string
QueryCSV string
TTL time.Duration
Ephemeral bool
UserID string
GroupID string
ControlBaseURL string
StreamTicketMode string
StreamTicketURL string
StreamSourceID string
}
type personalListOptions struct {
Category string
EnabledOnly bool
IncludePending bool
Format string
}
type personalStatusOptions struct {
EventKey string
Status string
SubscribeID string
Format string
ControlBaseURL string
StreamSourceID string
}
type personalStopOptions struct {
SubscribeID string
All bool
ControlBaseURL string
StreamSourceID string
}
type personalStreamSourceOptions struct {
ConfigDir string
Identity personal.Identity
TicketMode string
TicketURL string
ClientIDOverride string
}
func newEventSchemaCommand() *cobra.Command {
var asIdentity string
var formatRaw string
cmd := &cobra.Command{
Use: "schema <event_key>",
Short: "显示事件 schema",
Args: cobra.ExactArgs(1),
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := normalizeEventAs(asIdentity)
if err != nil {
return err
}
if as != "user" {
return fmt.Errorf("event schema is only supported with --as user")
}
def, ok := personal.Lookup(args[0])
if !ok {
return fmt.Errorf("unknown personal event key %q", args[0])
}
if !def.Public {
return personal.PublicAvailabilityError(args[0])
}
return renderPersonalSchema(c.OutOrStdout(), def, formatRaw)
},
}
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
hideEventInternalFlags(cmd, "as")
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
Name: "event_key",
Type: "string",
Description: "要查询 payload 字段定义的个人事件码",
Required: true,
Index: 0,
})
return cmd
}
func runPersonalEventList(c *cobra.Command, opts personalListOptions) error {
items := personal.Catalog(opts.Category, opts.EnabledOnly, opts.IncludePending)
if opts.Format == "json" {
enc := json.NewEncoder(c.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(items)
}
tw := tabwriter.NewWriter(c.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "EVENT_KEY\tRULE\tSTATUS\tDESCRIPTION")
for _, it := range items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n",
it.EventKey, it.RuleType, it.Status, it.Description)
}
return tw.Flush()
}
func renderPersonalSchema(w io.Writer, def personal.Definition, format string) error {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
format = "json"
}
if format != "json" {
return fmt.Errorf("event schema only supports json output")
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(personal.BuildSchemaDocument(def))
}
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if opts.Common.DryRun {
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: opts.EventKey,
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
DryRun: true,
}
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
return consume.Run(ctx, cfg)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
sub, eventKey, ruleType, err := ensurePersonalSubscription(ctx, client, identity, opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
if sub.SubscribeID == "" {
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
}
if err := personal.UpsertRunState(workDir, personal.RunState{
SubscribeID: sub.SubscribeID,
EventKey: eventKey,
RuleType: ruleType,
ClientID: identity.ClientID,
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
return fmt.Errorf("event consume --as user: save run state: %w", err)
}
cleanup := func() {
_ = client.DeleteSubscription(context.Background(), sub.SubscribeID)
_ = personal.RemoveRunStates(workDir, []string{sub.SubscribeID})
}
// Ownership-based cleanup (AI-subprocess contract, aligned with
// lark-cli): a subscription this run CREATED is unsubscribed on exit
// (any exit — SIGTERM / stdin-EOF / limit / timeout / error), so nothing
// leaks server-side. A subscription REUSED via --subscribe-id is left
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
// either way.
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
if opts.Ephemeral || selfCreated {
defer cleanup()
}
cfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
EventKey: eventKey,
Format: normalised,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
Foreground: opts.Common.Foreground,
Force: opts.Common.Force,
}
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
// run (see shouldWatchStdinEOF).
if shouldWatchStdinEOF(opts.Common.MaxEvents, opts.Common.Duration) {
cfg.Stdin = c.InOrStdin()
}
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
if opts.DebugRawEvents && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
workDir, filepath.Join(workDir, "bus.log"))
}
if err := consume.ValidateConfig(cfg); err != nil {
return err
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := consume.ValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
return err
}
}
if opts.Common.Foreground {
src, err := newPersonalStreamSource(ctx, personalStreamSourceOptions{
ConfigDir: configDir,
Identity: identity,
TicketMode: opts.StreamTicketMode,
TicketURL: opts.StreamTicketURL,
})
if err != nil {
if !opts.Ephemeral {
cleanup()
}
return err
}
busCfg := bus.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
Source: src,
}
bus.ApplyEnvTuning(&busCfg)
err = bus.Run(ctx, busCfg)
if err != nil && !opts.Ephemeral {
cleanup()
}
return err
}
err = consume.Run(ctx, cfg)
if err != nil && !opts.Ephemeral {
cleanup()
}
return err
}
func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOptions, subscribeID, eventKey string) {
if cfg == nil {
return
}
if opts.DebugRawEvents {
cfg.EventTypes = nil
cfg.Filter = ""
cfg.SubscribeID = ""
return
}
cfg.EventTypes = personalEventTypes(eventKey, opts.Common.EventTypes)
cfg.Filter = opts.Common.Filter
cfg.SubscribeID = strings.TrimSpace(subscribeID)
}
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
sub, err := client.GetSubscription(ctx, opts.SubscribeID)
if err != nil {
return nil, "", "", err
}
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
if eventKey == "" {
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
}
if err := ensurePublicPersonalEvent(eventKey); err != nil {
return nil, "", "", err
}
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
if ruleType == "" {
if def, ok := personal.Lookup(eventKey); ok {
ruleType = def.RuleType
}
}
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
return sub, eventKey, ruleType, nil
}
if strings.TrimSpace(opts.EventKey) == "" {
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
}
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return nil, "", "", err
}
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
RuleType: opts.Rule,
UserID: opts.UserID,
GroupID: opts.GroupID,
})
if err != nil {
return nil, "", "", err
}
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
if err != nil {
return nil, "", "", err
}
req := personal.CreateSubscriptionRequest{
EventKey: opts.EventKey,
RuleType: ruleType,
Name: opts.Name,
RuleParam: ruleParam,
Filter: filter,
Delivery: map[string]any{"mode": "stream"},
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
}
if opts.TTL > 0 {
req.TTLSeconds = int64(opts.TTL.Seconds())
}
sub, err := client.CreateSubscription(ctx, req)
if err != nil {
return nil, "", "", err
}
return sub, opts.EventKey, ruleType, nil
}
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event status --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
entry := busctl.FindBusByIdentity(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
var qs busctl.EntryStatus
if entry != nil {
qs = busctl.QueryEntry(*entry)
} else {
qs = busctl.EntryStatus{Entry: busctl.BusEntry{
WorkDir: workDir,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
ClientIDHash: identityHash,
IdentityHash: identityHash,
State: busctl.BusStateNotRunning,
Meta: &bus.Meta{
ClientID: identity.ClientID,
Edition: editionName,
SourceKind: dwsevent.SourceKindPersonalStream,
IdentityHash: identityHash,
SourceID: identity.SourceID,
},
}}
}
status := opts.Status
if status == "" || status == "all" {
status = ""
}
subs, err := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity).ListSubscriptions(ctx, personal.ListOptions{
Status: status,
EventKey: opts.EventKey,
SubscribeID: opts.SubscribeID,
})
if err != nil {
return fmt.Errorf("event status --as user: %w", err)
}
if opts.Format == "json" {
enc := json.NewEncoder(c.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"identity": redactedPersonalIdentity(identity, identityHash),
"subscriptions": subs,
"bus": qs,
})
}
renderPersonalStatusText(c.OutOrStdout(), identity, identityHash, subs, qs)
return nil
}
func ensurePublicPersonalEvent(eventKey string) error {
eventKey = strings.TrimSpace(eventKey)
if eventKey == "" {
return nil
}
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
return personal.PublicAvailabilityError(eventKey)
}
return nil
}
func renderPersonalStatusText(w io.Writer, identity personal.Identity, identityHash string, subs []personal.Subscription, qs busctl.EntryStatus) {
fmt.Fprintf(w, "Personal identity: corp=%s user=%s client=%s source=%s hash=%s\n",
displayIdentityPart(identity.CorpID), displayIdentityPart(identity.UserID), identity.ClientID, identity.SourceID, identityHash)
fmt.Fprintf(w, "Bus: %s", qs.Entry.State)
if qs.Entry.HolderPID > 0 {
fmt.Fprintf(w, " pid=%d", qs.Entry.HolderPID)
}
fmt.Fprintf(w, "\nWorkdir: %s\n", qs.Entry.WorkDir)
if len(subs) == 0 {
fmt.Fprintln(w, "Subscriptions: none")
} else {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "SUBSCRIBE_ID\tEVENT_KEY\tRULE\tSTATUS\tSOURCE")
for _, sub := range subs {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
sub.SubscribeID, sub.EventKey, sub.RuleType, sub.Status, sub.SourceID)
}
_ = tw.Flush()
}
renderPersonalConsumers(w, qs)
}
func renderPersonalConsumers(w io.Writer, qs busctl.EntryStatus) {
if qs.Entry.State != busctl.BusStateRunning {
fmt.Fprintln(w, "Consumers: none")
return
}
if qs.Live == nil {
fmt.Fprintln(w, "Consumers: unavailable (status RPC failed)")
return
}
if len(qs.Live.Consumers) == 0 {
fmt.Fprintln(w, "Consumers: none")
return
}
fmt.Fprintln(w, "Consumers:")
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "PID\tEVENT_KEYS\tSUBSCRIBE_ID\tFILTER\tRECEIVED\tDROPPED")
for _, cs := range qs.Live.Consumers {
eventKeys := strings.Join(cs.EventTypes, ",")
if eventKeys == "" {
eventKeys = "(catch-all)"
}
subscribeID := displayPersonalStatusValue(cs.SubscribeID)
filter := displayPersonalStatusValue(cs.Filter)
fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%d\t%d\n",
cs.PID, eventKeys, subscribeID, filter, cs.Received, cs.Dropped)
}
_ = tw.Flush()
}
func displayPersonalStatusValue(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return "-"
}
return v
}
func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
ctx := c.Context()
explicitSubscribeID := strings.TrimSpace(opts.SubscribeID)
isSingleTarget := explicitSubscribeID != ""
if explicitSubscribeID != "" && opts.All {
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
}
if explicitSubscribeID == "" && !opts.All {
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
}
configDir := defaultConfigDir()
identity, err := resolvePersonalEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
subscribeIDs, err := personalStopTargets(workDir, explicitSubscribeID, opts.All)
if err != nil {
return fmt.Errorf("event stop --as user: %w", err)
}
client := personal.NewClient(personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
for _, id := range subscribeIDs {
if err := client.DeleteSubscription(ctx, id); err != nil {
return fmt.Errorf("event stop --as user: cancel subscription %s: %w", id, err)
}
}
if err := personal.RemoveRunStates(workDir, subscribeIDs); err != nil {
return fmt.Errorf("event stop --as user: update local state: %w", err)
}
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
}
remaining, err := personal.LoadRunStates(workDir)
if err != nil {
return fmt.Errorf("event stop --as user: load remaining local state: %w", err)
}
if len(remaining) > 0 {
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, "personal bus still running")
return nil
}
busState := "personal bus stopped"
if err := busctl.Stop(busctl.StopConfig{WorkDir: workDir}); err != nil {
if errors.Is(err, busctl.ErrNotRunning) {
busState = "personal bus is not running"
} else {
return err
}
}
printPersonalStopResult(c.OutOrStdout(), subscribeIDs, isSingleTarget, busState)
return nil
}
func personalStopTargets(workDir, explicit string, all bool) ([]string, error) {
explicit = strings.TrimSpace(explicit)
if explicit != "" && all {
return nil, fmt.Errorf("subscribe_id and --all are mutually exclusive")
}
if explicit != "" {
return []string{explicit}, nil
}
if !all {
return nil, fmt.Errorf("subscribe_id is required unless --all is set")
}
states, err := personal.LoadRunStates(workDir)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(states))
for _, st := range states {
if st.SubscribeID != "" {
ids = append(ids, st.SubscribeID)
}
}
sort.Strings(ids)
return ids, nil
}
func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error {
targets := make(map[string]struct{}, len(subscribeIDs))
for _, id := range subscribeIDs {
id = strings.TrimSpace(id)
if id != "" {
targets[id] = struct{}{}
}
}
if ipcEndpoint == "" || len(targets) == 0 {
return nil
}
status, err := busctl.QueryStatus(ipcEndpoint)
if err != nil {
return nil
}
signalled := make(map[int]struct{})
for _, consumer := range status.Consumers {
if _, ok := targets[strings.TrimSpace(consumer.SubscribeID)]; !ok {
continue
}
if consumer.PID <= 0 || consumer.PID == os.Getpid() {
continue
}
if _, ok := signalled[consumer.PID]; ok {
continue
}
proc, err := os.FindProcess(consumer.PID)
if err != nil {
return fmt.Errorf("find consume pid=%d: %w", consumer.PID, err)
}
if err := proc.Signal(os.Interrupt); err != nil && !errors.Is(err, os.ErrProcessDone) {
return fmt.Errorf("signal consume pid=%d: %w", consumer.PID, err)
}
signalled[consumer.PID] = struct{}{}
}
return nil
}
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
if single && len(subscribeIDs) == 1 {
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
return
}
fmt.Fprintf(w, "cancelled %d personal subscription(s); %s\n", len(subscribeIDs), busState)
}
func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceIDOverride string) (personal.Identity, error) {
accessToken, err := ResolveAuxiliaryAccessToken(ctx, configDir, "")
if err != nil {
return personal.Identity{}, err
}
tokenData, _ := authpkg.LoadTokenData(configDir)
var corpID, userID, clientID, refreshToken string
if tokenData != nil {
corpID = tokenData.CorpID
userID = tokenData.UserID
clientID = tokenData.ClientID
refreshToken = tokenData.RefreshToken
}
if corpID == "" {
corpID = resolveRuntimeDefault(ctx, "$corpId")
}
if userID == "" {
userID = resolveRuntimeDefault(ctx, "$currentUserId")
}
if clientID == "" {
clientID = authpkg.ClientID()
}
if clientID == "" {
if id, _, _, _, err := authpkg.ResolveAppCredentialsStrict(configDir); err == nil {
clientID = id
}
}
if clientID == "" {
return personal.Identity{}, fmt.Errorf("cannot resolve OAuth client_id for personal events")
}
sourceID := strings.TrimSpace(sourceIDOverride)
if sourceID == "" {
sourceID = personalEventStreamSourceID("")
}
localSubject := ""
if strings.TrimSpace(corpID) == "" || strings.TrimSpace(userID) == "" {
localSubject = personalTokenSubject("refresh", refreshToken)
if localSubject == "" {
localSubject = personalTokenSubject("access", accessToken)
}
}
return personal.Identity{
AccessToken: accessToken,
LocalSubject: localSubject,
CorpID: corpID,
UserID: userID,
ClientID: clientID,
SourceID: sourceID,
}, nil
}
func personalTokenSubject(kind, token string) string {
token = strings.TrimSpace(token)
if token == "" {
return ""
}
sum := sha256.Sum256([]byte(token))
return strings.TrimSpace(kind) + ":" + hex.EncodeToString(sum[:])
}
func resolveRuntimeDefault(ctx context.Context, key string) string {
if fnMap := edition.Get().RuntimeDefaults; fnMap != nil {
if fn := fnMap()[key]; fn != nil {
if v, ok := fn(ctx); ok {
return strings.TrimSpace(v)
}
}
}
return ""
}
func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptions) (*source.PersonalSource, error) {
mode := strings.TrimSpace(opts.TicketMode)
if mode == "" {
mode = "normal"
}
if mode != "normal" && mode != "custom" {
return nil, fmt.Errorf("stream ticket mode must be normal or custom")
}
ticketURL := strings.TrimSpace(opts.TicketURL)
if ticketURL == "" {
ticketURL = personalEventStreamTicketURL("", opts.ConfigDir)
}
clientID := opts.Identity.ClientID
clientSecret := ""
if mode == "custom" {
resolvedID, secret, _, _, err := authpkg.ResolveAppCredentialsStrict(opts.ConfigDir)
if err != nil {
return nil, err
}
if opts.ClientIDOverride != "" {
clientID = opts.ClientIDOverride
} else if clientID == "" {
clientID = resolvedID
}
clientSecret = secret
}
_ = ctx
return source.NewPersonal(source.PersonalConfig{
AccessToken: opts.Identity.AccessToken,
ClientID: clientID,
ClientSecret: clientSecret,
SourceID: opts.Identity.SourceID,
TicketURL: ticketURL,
TicketMode: mode,
HTTPClient: &http.Client{Timeout: 30 * time.Second},
})
}
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string {
args := []string{
"--source-kind", string(dwsevent.SourceKindPersonalStream),
"--stream-source-id", identity.SourceID,
}
// Forward the organization so the detached _bus child resolves
// credentials for the SAME profile the parent used. Without this the
// child falls back to the default profile's token slot and fails to
// authenticate the personal stream for a non-default `--profile`
// (symptom: "bus child reported startup failure on ready pipe", no
// bus.log). --profile accepts a corpId; the root pre-parses it into the
// runtime profile before the _bus handler resolves the identity.
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
args = append(args, "--profile", cid)
}
if strings.TrimSpace(ticketMode) != "" {
args = append(args, "--stream-ticket-mode", ticketMode)
}
if strings.TrimSpace(ticketURL) != "" {
args = append(args, "--stream-ticket-url", ticketURL)
}
return args
}
func personalEventTypes(eventKey string, explicit []string) []string {
if len(explicit) > 0 {
return explicit
}
if strings.TrimSpace(eventKey) == "" {
return nil
}
return []string{eventKey}
}
func redactedPersonalIdentity(identity personal.Identity, identityHash string) map[string]string {
return map[string]string{
"corp_id": displayIdentityPart(identity.CorpID),
"user_id": displayIdentityPart(identity.UserID),
"client_id": identity.ClientID,
"source_id": identity.SourceID,
"identity_hash": identityHash,
}
}
func displayIdentityPart(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return "unknown"
}
return v
}
func firstNonEmptyPersonalString(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func personalEventControlBaseURL(raw, configDir string) string {
if v := strings.TrimSpace(raw); v != "" {
return strings.TrimRight(v, "/")
}
return personalEventMCPBaseURL(configDir) + personal.DefaultBasePath
}
func personalEventStreamTicketURL(raw, configDir string) string {
if v := strings.TrimSpace(raw); v != "" {
return strings.TrimRight(v, "/")
}
return personalEventMCPBaseURL(configDir) + "/stream/connections/ticket"
}
func personalEventStreamSourceID(raw string) string {
if v := strings.TrimSpace(raw); v != "" {
return v
}
if v := strings.TrimSpace(edition.PersonalEventSourceID()); v != "" {
return v
}
return "open"
}
func personalEventMCPBaseURL(configDir string) string {
if v := configuredMCPBaseURL(configDir); v != "" {
return strings.TrimRight(v, "/")
}
return config.DefaultMCPBaseURL
}
func configuredMCPBaseURL(configDir string) string {
if strings.TrimSpace(configDir) == "" {
configDir = defaultConfigDir()
}
data, err := os.ReadFile(filepath.Join(configDir, "mcp_url"))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
+131
View File
@@ -0,0 +1,131 @@
// 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 (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
)
func TestApplyPersonalConsumeFiltersDebugRawEvents(t *testing.T) {
cfg := consume.Config{}
opts := personalConsumeOptions{
DebugRawEvents: true,
Common: commonConsumeOptions{
EventTypes: []string{"should-not-survive"},
Filter: "^should-not-survive$",
},
}
applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o")
if cfg.EventTypes != nil || cfg.Filter != "" || cfg.SubscribeID != "" {
t.Fatalf("raw debug filters = eventTypes=%#v filter=%q subscribeID=%q, want catch-all", cfg.EventTypes, cfg.Filter, cfg.SubscribeID)
}
}
func TestApplyPersonalConsumeFiltersDefault(t *testing.T) {
cfg := consume.Config{}
opts := personalConsumeOptions{Common: commonConsumeOptions{Filter: "^user_im_"}}
applyPersonalConsumeFilters(&cfg, opts, "sub-1", "user_im_message_receive_o2o")
if len(cfg.EventTypes) != 1 || cfg.EventTypes[0] != "user_im_message_receive_o2o" {
t.Fatalf("eventTypes = %#v", cfg.EventTypes)
}
if cfg.Filter != "^user_im_" || cfg.SubscribeID != "sub-1" {
t.Fatalf("filter=%q subscribeID=%q", cfg.Filter, cfg.SubscribeID)
}
}
func TestEventConsumeDebugRawEventsRequiresUserMode(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--as", "app", "--debug-raw-events"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("Execute() error = %v, want public availability guard", err)
}
}
func TestEventConsumeAsAppRejectedBeforeEventKeyValidation(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--as", "app", personal.EventSingleChat})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("Execute() error = %v, want public availability guard", err)
}
}
func TestEventConsumePersonalParamSpecFlags(t *testing.T) {
cmd := newEventConsumeCommand()
for _, name := range []string{"user", "group", "query"} {
if cmd.Flags().Lookup(name) == nil {
t.Fatalf("flag --%s is not registered", name)
}
}
for _, name := range []string{
"peer-user-id",
"peer-union-id",
"sender-user-id",
"sender-union-id",
"open-conversation-id",
"keyword",
} {
if cmd.Flags().Lookup(name) != nil {
t.Fatalf("retired flag --%s is still registered", name)
}
}
}
func TestEventConsumeRetiredPersonalFlagsAreUnknown(t *testing.T) {
for _, name := range []string{
"peer-user-id",
"peer-union-id",
"sender-user-id",
"sender-union-id",
"open-conversation-id",
"keyword",
} {
t.Run(name, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{personal.EventSingleChat, "--" + name, "x"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "unknown flag: --"+name) {
t.Fatalf("Execute() error = %v, want unknown flag", err)
}
})
}
}
func TestEventConsumeAsAppRejectedBeforePersonalParamSpecFlags(t *testing.T) {
for _, args := range [][]string{
{"--as", "app", "--user", "507971"},
{"--as", "app", "--group", "cid"},
{"--as", "app", "--query", "报警"},
} {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs(args)
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("Execute(%v) error = %v, want public availability guard", args, err)
}
}
}
@@ -0,0 +1,211 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestResolvePersonalEventIdentityUsesCorpUserWhenAvailable(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
})
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
if err != nil {
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
}
if identity.LocalSubject != "" {
t.Fatalf("LocalSubject = %q, want empty when corp/user are available", identity.LocalSubject)
}
wantKey := "corp_user\x00corp-1\x00user-1\x00client-1\x00pre_open_source"
if got := identity.Key(); got != wantKey {
t.Fatalf("identity key = %q, want %q", got, wantKey)
}
}
func TestResolvePersonalEventIdentityFallsBackToRefreshTokenSubject(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
ClientID: "client-1",
})
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
if err != nil {
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
}
wantSubject := personalTokenSubject("refresh", "refresh-1")
if identity.LocalSubject != wantSubject {
t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject)
}
if strings.Contains(identity.Key(), "refresh-1") || strings.Contains(identity.Key(), "access-1") {
t.Fatalf("identity key leaked raw token: %q", identity.Key())
}
body, err := json.Marshal(redactedPersonalIdentity(identity, "identity-hash-1"))
if err != nil {
t.Fatalf("marshal redacted identity: %v", err)
}
if strings.Contains(string(body), wantSubject) || strings.Contains(string(body), "refresh-1") || strings.Contains(string(body), "access-1") {
t.Fatalf("redacted identity leaked local subject/token: %s", string(body))
}
if !strings.Contains(string(body), "unknown") {
t.Fatalf("redacted identity should mark missing corp/user as unknown: %s", string(body))
}
}
func TestResolvePersonalEventIdentityFallsBackToAccessTokenSubject(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
ExpiresAt: time.Now().Add(time.Hour),
ClientID: "client-1",
})
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "pre_open_source")
if err != nil {
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
}
wantSubject := personalTokenSubject("access", "access-1")
if identity.LocalSubject != wantSubject {
t.Fatalf("LocalSubject = %q, want %q", identity.LocalSubject, wantSubject)
}
var out bytes.Buffer
renderPersonalStatusText(&out, identity, "identity-hash-1", nil, busctl.EntryStatus{
Entry: busctl.BusEntry{WorkDir: "wd", State: busctl.BusStateNotRunning},
})
rendered := out.String()
if !strings.Contains(rendered, "corp=unknown user=unknown") {
t.Fatalf("status output = %q, want unknown corp/user", rendered)
}
if strings.Contains(rendered, wantSubject) || strings.Contains(rendered, "access-1") {
t.Fatalf("status output leaked local subject/token: %q", rendered)
}
}
func TestResolvePersonalEventIdentityDefaultsSourceIDToOpen(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
ExpiresAt: time.Now().Add(time.Hour),
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
})
identity, err := resolvePersonalEventIdentity(context.Background(), configDir, "")
if err != nil {
t.Fatalf("resolvePersonalEventIdentity() error = %v", err)
}
if identity.SourceID != "open" {
t.Fatalf("SourceID = %q, want open", identity.SourceID)
}
}
func TestPersonalEventDefaultsUseProductionWithoutMCPConfig(t *testing.T) {
dir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", dir)
prev := edition.Get()
edition.Override(&edition.Hooks{})
t.Cleanup(func() { edition.Override(prev) })
if got := personalEventControlBaseURL("", dir); got != "https://mcp.dingtalk.com/dws" {
t.Fatalf("personalEventControlBaseURL() = %q, want production control URL", got)
}
if got := personalEventStreamTicketURL("", dir); got != "https://mcp.dingtalk.com/stream/connections/ticket" {
t.Fatalf("personalEventStreamTicketURL() = %q, want production ticket URL", got)
}
if got := personalEventStreamSourceID(""); got != "open" {
t.Fatalf("personalEventStreamSourceID() = %q, want open", got)
}
if got := config.GetMCPBaseURL(); got != "https://mcp.dingtalk.com" {
t.Fatalf("config.GetMCPBaseURL() = %q, want production MCP URL", got)
}
}
func TestPersonalEventDefaultsRespectExplicitAndMCPConfig(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "mcp_url"), []byte("https://custom-mcp.example.com\n"), 0o600); err != nil {
t.Fatalf("write mcp_url: %v", err)
}
if got := personalEventControlBaseURL("", dir); got != "https://custom-mcp.example.com/dws" {
t.Fatalf("personalEventControlBaseURL() = %q, want configured control URL", got)
}
if got := personalEventStreamTicketURL("", dir); got != "https://custom-mcp.example.com/stream/connections/ticket" {
t.Fatalf("personalEventStreamTicketURL() = %q, want configured ticket URL", got)
}
if got := personalEventControlBaseURL(" https://override.example.com/dws/ ", dir); got != "https://override.example.com/dws" {
t.Fatalf("explicit control URL = %q, want trimmed override", got)
}
if got := personalEventStreamTicketURL(" https://override.example.com/ticket/ ", dir); got != "https://override.example.com/ticket" {
t.Fatalf("explicit ticket URL = %q, want trimmed override", got)
}
if got := personalEventStreamSourceID("flag_source"); got != "flag_source" {
t.Fatalf("explicit sourceID = %q, want flag_source", got)
}
}
func TestPersonalEventSourceIDPrefersEditionOverride(t *testing.T) {
prev := edition.Get()
edition.Override(&edition.Hooks{PersonalEventSourceID: "edition_source"})
t.Cleanup(func() { edition.Override(prev) })
if got := personalEventStreamSourceID(""); got != "edition_source" {
t.Fatalf("personalEventStreamSourceID() = %q, want edition_source", got)
}
if got := personalEventStreamSourceID("flag_source"); got != "flag_source" {
t.Fatalf("explicit sourceID = %q, want flag_source", got)
}
}
func setupPersonalIdentityToken(t *testing.T, data *authpkg.TokenData) string {
t.Helper()
configDir := t.TempDir()
raw, err := json.Marshal(data)
if err != nil {
t.Fatalf("marshal token data: %v", err)
}
prev := edition.Get()
edition.Override(&edition.Hooks{
LoadToken: func(dir string) ([]byte, error) {
if filepath.Clean(dir) != filepath.Clean(configDir) {
t.Fatalf("LoadToken dir = %q, want %q", dir, configDir)
}
return raw, nil
},
})
t.Cleanup(func() { edition.Override(prev) })
return configDir
}
+349
View File
@@ -0,0 +1,349 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/spf13/cobra"
)
func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{name: "table", args: []string{"--as", "user"}},
{name: "json", args: []string{"--as", "user", "--format", "json"}},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := newEventListCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
got := out.String()
assertPersonalOutputHidesSchemaIDs(t, got)
if strings.Contains(got, personal.EventFromUser) {
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
}
})
}
}
func TestEventListDefaultsToUser(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
cmd := newEventListCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
got := out.String()
if !strings.Contains(got, personal.EventSingleChat) || !strings.Contains(got, "EVENT_KEY") {
t.Fatalf("list output = %s, want personal event catalog", got)
}
if strings.Contains(got, personal.EventFromUser) {
t.Fatalf("list output exposed hidden event %s: %s", personal.EventFromUser, got)
}
if strings.Contains(got, "CLIENT_ID") || strings.Contains(got, "ClientSecret") {
t.Fatalf("list default appears to use legacy application output: %s", got)
}
}
func TestEventPublicHelpHidesAppMode(t *testing.T) {
for _, tc := range []struct {
name string
cmd *cobra.Command
}{
{name: "consume", cmd: newEventConsumeCommand()},
{name: "list", cmd: newEventListCommand()},
{name: "schema", cmd: newEventSchemaCommand()},
{name: "status", cmd: newEventStatusCommand()},
{name: "stop", cmd: newEventStopCommand()},
} {
t.Run(tc.name, func(t *testing.T) {
var out bytes.Buffer
tc.cmd.SetOut(&out)
tc.cmd.SetArgs([]string{"--help"})
if tc.name == "schema" {
tc.cmd.SetArgs([]string{personal.EventSingleChat, "--help"})
}
if err := tc.cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
got := out.String()
for _, hidden := range []string{"--as", "user|app", "应用事件" + " Stream"} {
if strings.Contains(got, hidden) {
t.Fatalf("%s help leaked %q:\n%s", tc.name, hidden, got)
}
}
})
}
}
func TestEventListAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) {
cmd := newEventListCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--all"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "--all are not supported for personal events") {
t.Fatalf("Execute() error = %v, want unsupported flag validation", err)
}
}
func TestEventAsAppRejected(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
for _, cmd := range []*cobra.Command{
newEventListCommand(),
newEventStatusCommand(),
newEventConsumeCommand(),
newEventStopCommand(),
newEventSchemaCommand(),
} {
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--as", "app"})
if cmd.Use == "schema <event_key>" {
cmd.SetArgs([]string{personal.EventSingleChat, "--as", "app"})
}
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("%s Execute() error = %v, want public availability guard", cmd.Use, err)
}
}
}
func TestEventStatusAppOnlyFlagsRejectedForPersonalEvents(t *testing.T) {
cmd := newEventStatusCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--all", "--fail-on-orphan"})
err := cmd.Execute()
if err == nil ||
!strings.Contains(err.Error(), "--all") ||
!strings.Contains(err.Error(), "--fail-on-orphan") ||
!strings.Contains(err.Error(), "not supported for personal events") {
t.Fatalf("Execute() error = %v, want unsupported flag validation", err)
}
}
func TestPersonalEventSchemaHidesSchemaIDs(t *testing.T) {
for _, tc := range []struct {
name string
args []string
}{
{name: "default", args: []string{personal.EventSingleChat, "--as", "user"}},
{name: "json", args: []string{personal.EventSingleChat, "--as", "user", "--format", "json"}},
} {
t.Run(tc.name, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs(tc.args)
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
assertPersonalOutputHidesSchemaIDs(t, out.String())
if strings.Contains(out.String(), "Schemas") {
t.Fatalf("schema output contains Schemas line: %s", out.String())
}
})
}
}
func TestPersonalEventSchemaUsesSingleJSONSchema(t *testing.T) {
for _, eventKey := range []string{
personal.EventMention,
personal.EventSingleChat,
personal.EventInChat,
} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
got := out.String()
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output for %s is not JSON: %v\n%s", eventKey, err, got)
}
for _, want := range []string{
"event_key",
"display_name",
"description",
"category",
"rule_type",
"required_params",
"jq_root_path",
"schema",
"event_id",
"timestamp",
"subscribe_id",
"content",
"sender",
"sender_open_dingtalk_id",
"conversation_id",
"message_id",
"create_time",
"event_time",
} {
if !strings.Contains(got, want) {
t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got)
}
}
for _, leaked := range []string{
"message.text",
"chat.openConversationId",
"sender.userId",
"sender.unionId",
"auth",
"resolved_output_schema",
"decoded_data_schema",
"filter_schema",
"payload_schema",
"output_schema",
"data_json_path",
"headers",
"audit",
"tenant",
"subject",
"traceId",
"msgIdMetaq",
"at_users",
"sender_user_id",
} {
if strings.Contains(got, leaked) {
t.Fatalf("schema output for %s leaked %q: %s", eventKey, leaked, got)
}
}
if doc["jq_root_path"] != ".data | fromjson" {
t.Fatalf("jq_root_path = %#v, want .data | fromjson", doc["jq_root_path"])
}
schema, ok := doc["schema"].(map[string]any)
if !ok {
t.Fatalf("schema = %#v, want object", doc["schema"])
}
props, ok := schema["properties"].(map[string]any)
if !ok {
t.Fatalf("schema.properties = %#v, want object", schema["properties"])
}
if _, ok := props["content"].(map[string]any); !ok {
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
}
})
}
}
func TestEventSchemaDefaultsToUser(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{personal.EventSingleChat})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["event_key"] != personal.EventSingleChat {
t.Fatalf("event_key = %#v, want %s", doc["event_key"], personal.EventSingleChat)
}
}
func TestPersonalEventFromUserIsNotPubliclyAvailable(t *testing.T) {
for _, tc := range []struct {
name string
cmd *cobra.Command
args []string
}{
{
name: "schema",
cmd: newEventSchemaCommand(),
args: []string{personal.EventFromUser},
},
{
name: "consume",
cmd: newEventConsumeCommand(),
args: []string{personal.EventFromUser, "--user", "507971", "--dry-run"},
},
{
name: "status",
cmd: newEventStatusCommand(),
args: []string{"--event", personal.EventFromUser},
},
} {
t.Run(tc.name, func(t *testing.T) {
tc.cmd.SilenceUsage = true
tc.cmd.SilenceErrors = true
tc.cmd.SetArgs(tc.args)
err := tc.cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "event "+personal.EventFromUser+" is not publicly available yet") {
t.Fatalf("Execute() error = %v, want not publicly available", err)
}
})
}
}
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{personal.EventSingleChat, "--format", "table"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "event schema only supports json output") {
t.Fatalf("Execute() error = %v, want json-only format validation", err)
}
}
func TestEventAsBotRejected(t *testing.T) {
cmd := newEventListCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--as", "bot"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("Execute() error = %v, want public availability guard", err)
}
}
func assertPersonalOutputHidesSchemaIDs(t *testing.T, out string) {
t.Helper()
for _, leaked := range []string{"SCHEMA_IDS", "schema_ids", "im_msg_23", "im_msg_29"} {
if strings.Contains(out, leaked) {
t.Fatalf("output leaked %q: %s", leaked, out)
}
}
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func TestRenderPersonalStatusTextShowsConsumersWithoutSubscriptions(t *testing.T) {
var out bytes.Buffer
renderPersonalStatusText(&out, personal.Identity{
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
SourceID: "source-1",
}, "identity-hash-1", nil, busctl.EntryStatus{
Entry: busctl.BusEntry{
WorkDir: "wd",
State: busctl.BusStateRunning,
HolderPID: 100,
},
Live: &transport.StatusResp{
Consumers: []transport.StatusConsumer{
{
PID: 12345,
EventTypes: []string{"user_im_message_receive_o2o"},
SubscribeID: "subId-1",
Filter: "content",
Received: 3,
Dropped: 1,
},
{
PID: 12346,
Received: 5,
},
},
},
})
got := out.String()
for _, want := range []string{
"Subscriptions: none",
"Consumers:",
"PID",
"EVENT_KEYS",
"SUBSCRIBE_ID",
"RECEIVED",
"DROPPED",
"12345",
"user_im_message_receive_o2o",
"subId-1",
"content",
"3",
"1",
"(catch-all)",
"-",
} {
if !strings.Contains(got, want) {
t.Fatalf("status output missing %q:\n%s", want, got)
}
}
}
func TestRenderPersonalStatusTextConsumersUnavailableWhenRPCFails(t *testing.T) {
var out bytes.Buffer
renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{
Entry: busctl.BusEntry{
WorkDir: "wd",
State: busctl.BusStateRunning,
HolderPID: 100,
},
})
if got := out.String(); !strings.Contains(got, "Consumers: unavailable (status RPC failed)") {
t.Fatalf("status output = %q, want unavailable consumers", got)
}
}
func TestRenderPersonalStatusTextConsumersNoneWhenBusNotRunning(t *testing.T) {
var out bytes.Buffer
renderPersonalStatusText(&out, personal.Identity{ClientID: "client-1", SourceID: "source-1"}, "identity-hash-1", nil, busctl.EntryStatus{
Entry: busctl.BusEntry{
WorkDir: "wd",
State: busctl.BusStateNotRunning,
},
})
if got := out.String(); !strings.Contains(got, "Consumers: none") {
t.Fatalf("status output = %q, want no consumers", got)
}
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"sort"
"testing"
"github.com/spf13/cobra"
)
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
root := &cobra.Command{Use: "dws"}
event := newEventCommand()
unregistered := &cobra.Command{Use: "unregistered", Run: func(*cobra.Command, []string) {}}
root.AddCommand(event, unregistered)
hideNonDirectRuntimeCommands(root)
if event.Hidden {
t.Fatal("built-in event command was hidden by the direct-runtime visibility filter")
}
if !unregistered.Hidden {
t.Fatal("control command outside the built-in/direct-runtime sets remained visible")
}
var leaves []string
for _, command := range event.Commands() {
if command.Hidden || !command.Runnable() {
continue
}
leaves = append(leaves, command.Name())
}
sort.Strings(leaves)
want := []string{"consume", "list", "schema", "status", "stop"}
if len(leaves) != len(want) {
t.Fatalf("public event leaves = %v, want %v", leaves, want)
}
for index := range want {
if leaves[index] != want[index] {
t.Fatalf("public event leaves = %v, want %v", leaves, want)
}
}
}
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
root := &cobra.Command{Use: "dws"}
builtIn := newEventCommand()
root.AddCommand(builtIn)
pluginEvent := &cobra.Command{Use: "event", Run: func(*cobra.Command, []string) {}}
addPluginCommandsSafe(root, []*cobra.Command{pluginEvent})
var eventCommands []*cobra.Command
for _, command := range root.Commands() {
if command.Name() == "event" {
eventCommands = append(eventCommands, command)
}
}
if len(eventCommands) != 1 || eventCommands[0] != builtIn {
t.Fatalf("event command after plugin registration = %p (%d matches), want built-in %p", firstEventCommand(eventCommands), len(eventCommands), builtIn)
}
if pluginEvent.Parent() != nil {
t.Fatal("conflicting plugin event command was attached to the root")
}
}
func firstEventCommand(commands []*cobra.Command) *cobra.Command {
if len(commands) == 0 {
return nil
}
return commands[0]
}
+64
View File
@@ -0,0 +1,64 @@
// 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 (
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
)
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
// shape: --max-events / --duration are the lifecycle control.
func TestShouldWatchStdinEOF_BoundedIsNeverArmed(t *testing.T) {
if shouldWatchStdinEOF(1, 0) {
t.Error("--max-events set should not arm stdin watcher")
}
if shouldWatchStdinEOF(0, 5*time.Second) {
t.Error("--duration set should not arm stdin watcher")
}
if shouldWatchStdinEOF(3, 2*time.Second) {
t.Error("both bounds set should not arm stdin watcher")
}
}
// Regression: the detached _bus child must receive --profile so it resolves
// credentials for the same organization as the parent. Missing it made a
// non-default `--profile` consume fail with "bus child reported startup
// failure on ready pipe" (no bus.log).
func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
args := personalBusSpawnArgs(personal.Identity{
CorpID: "dinga626d60c1128d449",
SourceID: "open",
}, "", "")
found := false
for i := 0; i+1 < len(args); i++ {
if args[i] == "--profile" && args[i+1] == "dinga626d60c1128d449" {
found = true
break
}
}
if !found {
t.Errorf("spawn args must forward --profile <corpId>; got %v", args)
}
// No CorpID → no --profile appended (avoid an empty flag value).
bare := personalBusSpawnArgs(personal.Identity{SourceID: "open"}, "", "")
for _, a := range bare {
if a == "--profile" {
t.Errorf("must not append --profile when CorpID is empty; got %v", bare)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
func TestEventStopRequiresTypedConfirmationBeforeMutation(t *testing.T) {
root, _ := newEventStopSafetyRoot()
root.SetArgs([]string{"event", "stop", "sub-1"})
err := root.Execute()
if err == nil {
t.Fatal("event stop without --yes or --dry-run unexpectedly succeeded")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
t.Fatalf("event stop confirmation error = %T %v, want typed validation error", err, err)
}
if appErr.Reason != "confirmation_required" {
t.Fatalf("event stop confirmation reason = %q, want confirmation_required", appErr.Reason)
}
for _, recoveryFlag := range []string{"--dry-run", "--yes"} {
if !strings.Contains(err.Error(), recoveryFlag) {
t.Fatalf("event stop confirmation error %q does not explain %s", err, recoveryFlag)
}
}
}
func TestEventStopDryRunPrecedesConfirmationAndReturnsPreview(t *testing.T) {
tests := []struct {
name string
args []string
wantAll bool
wantSubscribeID string
}{
{name: "single subscription", args: []string{"event", "stop", "sub-1", "--dry-run"}, wantSubscribeID: "sub-1"},
{name: "all subscriptions", args: []string{"--dry-run", "event", "stop", "--all"}, wantAll: true},
{name: "dry run wins over yes", args: []string{"event", "stop", "sub-2", "--yes", "--dry-run"}, wantSubscribeID: "sub-2"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
root, stdout := newEventStopSafetyRoot()
root.SetArgs(test.args)
if err := root.Execute(); err != nil {
t.Fatalf("event stop dry-run error = %v", err)
}
var preview map[string]any
if err := json.Unmarshal(stdout.Bytes(), &preview); err != nil {
t.Fatalf("decode event stop dry-run preview: %v\n%s", err, stdout.String())
}
if preview["dry_run"] != true || preview["action"] != "event.stop" || preview["identity"] != "user" {
t.Fatalf("event stop dry-run preview = %#v", preview)
}
if got, _ := preview["all"].(bool); got != test.wantAll {
t.Fatalf("event stop dry-run all = %v, want %v", got, test.wantAll)
}
if got, _ := preview["subscribe_id"].(string); got != test.wantSubscribeID {
t.Fatalf("event stop dry-run subscribe_id = %q, want %q", got, test.wantSubscribeID)
}
})
}
}
func TestEventStopDryRunDoesNotBypassTargetValidation(t *testing.T) {
for _, test := range []struct {
name string
args []string
want string
}{
{name: "missing target", args: []string{"event", "stop", "--dry-run"}, want: "subscribe_id is required unless --all is set"},
{name: "conflicting targets", args: []string{"event", "stop", "sub-1", "--all", "--dry-run"}, want: "subscribe_id and --all are mutually exclusive"},
} {
t.Run(test.name, func(t *testing.T) {
root, _ := newEventStopSafetyRoot()
root.SetArgs(test.args)
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("event stop dry-run validation error = %v, want %q", err, test.want)
}
})
}
}
func newEventStopSafetyRoot() (*cobra.Command, *bytes.Buffer) {
stdout := &bytes.Buffer{}
root := &cobra.Command{
Use: "dws",
SilenceErrors: true,
SilenceUsage: true,
}
root.SetOut(stdout)
root.SetErr(&bytes.Buffer{})
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
root.PersistentFlags().Bool("yes", false, "confirm execution")
event := &cobra.Command{Use: "event"}
event.AddCommand(newEventStopCommand())
root.AddCommand(event)
return root, stdout
}
+127
View File
@@ -0,0 +1,127 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
)
func TestEventStopHelpDescribesPersonalSubscription(t *testing.T) {
cmd := newEventStopCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{"--help"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
got := out.String()
for _, want := range []string{
"stop [subscribe_id]",
"取消个人事件订阅并停止本地消费",
"取消个人事件订阅并停止本地消费,清理对应本地消费状态",
} {
if !strings.Contains(got, want) {
t.Fatalf("help missing %q:\n%s", want, got)
}
}
for _, stale := range []string{"优雅停止 bus 守护进程", strings.Join([]string{"--as", "app"}, " "), "应用事件"} {
if strings.Contains(got, stale) {
t.Fatalf("help still contains stale public app wording %q:\n%s", stale, got)
}
}
}
func TestEventStopRequiresSubscribeIDOrAll(t *testing.T) {
cmd := newEventStopCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") {
t.Fatalf("Execute() error = %v, want subscribe_id requirement", err)
}
}
func TestEventStopSubscribeIDAndAllAreMutuallyExclusive(t *testing.T) {
cmd := newEventStopCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"subId-1", "--all"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "subscribe_id and --all are mutually exclusive") {
t.Fatalf("Execute() error = %v, want mutual exclusion", err)
}
}
func TestEventStopAsAppRejectsSubscribeID(t *testing.T) {
cmd := newEventStopCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{"--as", "app", "subId-1"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "app event is not publicly available yet") {
t.Fatalf("Execute() error = %v, want public availability guard", err)
}
}
func TestPersonalStopTargets(t *testing.T) {
workDir := t.TempDir()
if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-b"}); err != nil {
t.Fatalf("UpsertRunState() error = %v", err)
}
if err := personal.UpsertRunState(workDir, personal.RunState{SubscribeID: "sub-a"}); err != nil {
t.Fatalf("UpsertRunState() error = %v", err)
}
got, err := personalStopTargets(workDir, "sub-explicit", false)
if err != nil {
t.Fatalf("personalStopTargets(explicit) error = %v", err)
}
if want := []string{"sub-explicit"}; !reflect.DeepEqual(got, want) {
t.Fatalf("explicit targets = %#v, want %#v", got, want)
}
got, err = personalStopTargets(workDir, "", true)
if err != nil {
t.Fatalf("personalStopTargets(all) error = %v", err)
}
if want := []string{"sub-a", "sub-b"}; !reflect.DeepEqual(got, want) {
t.Fatalf("all targets = %#v, want %#v", got, want)
}
if _, err := personalStopTargets(workDir, "", false); err == nil || !strings.Contains(err.Error(), "subscribe_id is required unless --all is set") {
t.Fatalf("personalStopTargets(no target) error = %v, want required error", err)
}
if _, err := personalStopTargets(workDir, "sub-explicit", true); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("personalStopTargets(explicit+all) error = %v, want mutual exclusion", err)
}
}
func TestPrintPersonalStopResult(t *testing.T) {
var out bytes.Buffer
printPersonalStopResult(&out, []string{"sub-1"}, true, "personal bus stopped")
if got := out.String(); got != "cancelled personal subscription sub-1; personal bus stopped\n" {
t.Fatalf("single output = %q", got)
}
out.Reset()
printPersonalStopResult(&out, []string{"sub-1", "sub-2"}, false, "personal bus still running")
if got := out.String(); got != "cancelled 2 personal subscription(s); personal bus still running\n" {
t.Fatalf("multi output = %q", got)
}
}
+105
View File
@@ -0,0 +1,105 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
stderrors "errors"
"fmt"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
func TestFlagErrorWithSuggestions_authStructured(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "login", Run: func(*cobra.Command, []string) {}}
orig := fmt.Errorf("unknown flag: --json")
err := flagErrorWithSuggestions(cmd, orig)
var ae *apperrors.Error
if !stderrors.As(err, &ae) {
t.Fatalf("want *apperrors.Error, got %T", err)
}
if !strings.Contains(ae.Message, orig.Error()) {
t.Fatalf("Message = %q, want to contain %q", ae.Message, orig.Error())
}
// 尾部 hint:所有 flag 解析错误的 Message 都应以 See '<cmd> --help' for usage. 结尾
if !strings.HasSuffix(ae.Message, "See 'login --help' for usage.") {
t.Fatalf("Message tail = %q, want suffix See 'login --help' for usage.", ae.Message)
}
if ae.Reason != "unknown_flag" {
t.Fatalf("Reason = %q, want unknown_flag", ae.Reason)
}
if ae.Hint == "" || !strings.Contains(ae.Hint, "format json") {
t.Fatalf("Hint = %q", ae.Hint)
}
if ae.Cause != orig {
t.Fatalf("Cause = %v, want orig", ae.Cause)
}
if !stderrors.Is(err, orig) {
t.Fatal("errors.Is(err, orig) should hold via unwrap")
}
}
func TestFlagErrorWithSuggestions_unknownFlagHintAndFlags(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
cmd.Flags().String("start", "", "begin time")
_ = cmd.Flags().SetAnnotation("start", "x-cli-format", []string{"date-time"})
orig := fmt.Errorf("unknown flag: --starttime1")
err := flagErrorWithSuggestions(cmd, orig)
var ae *apperrors.Error
if !stderrors.As(err, &ae) {
t.Fatalf("want *apperrors.Error, got %T", err)
}
if ae.Reason != "unknown_flag" {
t.Fatalf("Reason = %q", ae.Reason)
}
if strings.Contains(ae.Hint, "Space required") {
t.Fatalf("false glue must not suggest space: %q", ae.Hint)
}
if !strings.Contains(ae.Hint, "help") {
t.Fatalf("expected help fallback in hint, got %q", ae.Hint)
}
if len(ae.AvailableFlags) != 1 || ae.AvailableFlags[0] != "start" {
t.Fatalf("AvailableFlags = %v, want [start]", ae.AvailableFlags)
}
// 尾部 hint 验证:非 alias 路径(SuggestFlagFix 命中)同样应带 See '... --help' for usage.
if !strings.HasSuffix(ae.Message, "See 'list --help' for usage.") {
t.Fatalf("Message tail = %q, want suffix See 'list --help' for usage.", ae.Message)
}
}
// TestFlagErrorWithSuggestions_fallbackTailHint 验证 fallback 路径(非 unknown flag 类错误,
// 如 missing required flag / ambiguous shorthand)也带尾部 See '<cmd> --help' for usage.
// 这是 wukong / docker / kubectl 的通用 UX——任何 flag 解析错误都给用户一条 help 入口。
func TestFlagErrorWithSuggestions_fallbackTailHint(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}}
orig := fmt.Errorf("required flag(s) \"to\" not set")
err := flagErrorWithSuggestions(cmd, orig)
// fallback 路径返回 plain error(非 *apperrors.Error),保持原 exit code 行为
var ae *apperrors.Error
if stderrors.As(err, &ae) {
t.Fatalf("fallback path should return plain error, got *apperrors.Error: %v", err)
}
msg := err.Error()
if !strings.Contains(msg, orig.Error()) {
t.Fatalf("err = %q, want to contain orig %q", msg, orig.Error())
}
if !strings.HasSuffix(msg, "See 'send --help' for usage.") {
t.Fatalf("err tail = %q, want suffix See 'send --help' for usage.", msg)
}
}
+3 -1
View File
@@ -29,6 +29,7 @@ type GlobalFlags struct {
JQ string
Mock bool
Output string
Profile string
Timeout int
Token string
Verbose bool
@@ -41,11 +42,12 @@ 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")
_ = cmd.PersistentFlags().MarkHidden("output")
cmd.PersistentFlags().StringVar(&flags.Profile, "profile", "", "一次性指定本次命令使用的组织 profile 名或 corpId;多个按 CSV 逗号分隔,如 corpA,corpB")
cmd.PersistentFlags().IntVar(&flags.Timeout, "timeout", 30, "HTTP 请求超时时间 (秒)")
cmd.PersistentFlags().StringVar(&flags.Token, "token", "", "Override the configured API token")
_ = cmd.PersistentFlags().MarkHidden("token")
-278
View File
@@ -1,278 +0,0 @@
package app
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/spf13/cobra"
)
func TestRootCommandDoesNotInjectPatchedHelpCommands(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 3, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("doc", "文档管理", nil, map[string]any{
"search_docs": map[string]any{
"cliName": "search",
"flags": map[string]any{},
},
}),
discoveryServerEntry("chat", "聊天管理", map[string]any{
"message": map[string]any{"description": "消息管理"},
}, map[string]any{
"list_messages": map[string]any{
"cliName": "list",
"group": "message",
"flags": map[string]any{},
},
}),
discoveryServerEntry("minutes", "听记管理", map[string]any{
"list": map[string]any{"description": "列表"},
}, map[string]any{
"list_minutes_mine": map[string]any{
"cliName": "mine",
"group": "list",
"flags": map[string]any{},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
for _, path := range []string{
"doc upload",
"chat message list-topic-replies",
"minutes list all",
} {
if cmd := lookupCommand(root, path); cmd != nil {
t.Fatalf("findCommand(%q) = %q, want nil", path, cmd.CommandPath())
}
}
}
func TestDynamicLeafHelpDoesNotUsePatchedExamplesOrFlagText(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
"create_ai_app": map[string]any{
"cliName": "create",
"flags": map[string]any{
"prompt": map[string]any{
"alias": "prompt",
},
},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"aiapp", "create", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(aiapp create --help) error = %v", err)
}
got := out.String()
if strings.Contains(got, "创建一个天气查询应用") {
t.Fatalf("leaf help still contains patched example:\n%s", got)
}
if strings.Contains(got, "创建 AI 应用的 prompt(必填)") {
t.Fatalf("leaf help still contains patched flag usage:\n%s", got)
}
if !strings.Contains(got, "--prompt string") {
t.Fatalf("leaf help missing dynamic prompt flag:\n%s", got)
}
}
func TestRootHelpUsesMCPOnlySummary(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
"create_ai_app": map[string]any{
"cliName": "create",
"flags": map[string]any{},
},
}),
discoveryServerEntry("aitable", "多维表管理", nil, map[string]any{
"list_bases": map[string]any{
"cliName": "list",
"flags": map[string]any{},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--help"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(--help) error = %v", err)
}
got := out.String()
for _, want := range []string{"Discovered MCP Services:", "aiapp", "AI应用管理", "aitable", "多维表管理"} {
if !strings.Contains(got, want) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理", "Flags:"} {
if strings.Contains(got, unwanted) {
t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got)
}
}
}
func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
"create_ai_app": map[string]any{
"cliName": "create",
"flags": map[string]any{
"prompt": map[string]any{
"alias": "prompt",
},
},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"aiapp", "--help"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(aiapp --help) error = %v", err)
}
got := out.String()
if !strings.Contains(got, "Usage:") || !strings.Contains(got, "Available Commands:") || !strings.Contains(got, "Flags:") {
t.Fatalf("subcommand help should still use cobra default sections:\n%s", got)
}
if strings.Contains(got, "Discovered MCP Services:") {
t.Fatalf("subcommand help should not render root-only MCP summary:\n%s", got)
}
}
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
root := NewRootCommand()
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
t.Fatal("upgrade command should be registered on root, but was not found")
}
}
func discoveryServerEntry(command, description string, groups, toolOverrides map[string]any) map[string]any {
cliMeta := map[string]any{
"id": command,
"command": command,
"description": description,
"toolOverrides": toolOverrides,
}
if len(groups) > 0 {
cliMeta["groups"] = groups
}
return map[string]any{
"server": map[string]any{
"name": command,
"description": description,
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/" + command,
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active",
"isLatest": true,
},
"com.dingtalk.mcp.registry/cli": cliMeta,
},
}
}
func lookupCommand(root *cobra.Command, path string) *cobra.Command {
if root == nil || path == "" {
return root
}
cmd := root
for _, part := range strings.Fields(path) {
found := false
for _, child := range cmd.Commands() {
if child.Name() == part {
cmd = child
found = true
break
}
}
if !found {
return nil
}
}
return cmd
}
+35 -478
View File
@@ -14,94 +14,59 @@
package app
import (
"context"
"encoding/json"
"log/slog"
"net"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/editionmerge"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/builtin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
"github.com/spf13/cobra"
)
func newLegacyPublicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
if fn := edition.Get().StaticServers; fn != nil {
injectStaticServers(fn())
// Static servers provided by the edition hook — skip Market discovery
// entirely. The overlay registers its own product commands via
// RegisterExtraCommands; we only add the open-source helpers here.
commands := helpers.NewPublicCommands(runner)
return mergeTopLevelCommands(commands)
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller) []*cobra.Command {
injectStaticServers()
helpers.InitDeps(caller)
commands := helpers.NewPublicCommands(runner)
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
// command tree, so distilled high-frequency operations mount alongside the
// built-ins. Conflicts with built-ins are skipped inside Load.
if _, err := userdef.Load(); err != nil {
slog.Warn("shortcut: failed to load user-defined shortcuts", "error", err)
}
dynamicCmds := loadDynamicCommands(ctx, runner)
helperCmds := helpers.NewPublicCommands(runner)
return mergeTopLevelCommands(pickCommands(dynamicCmds, helperCmds))
// Built-in + user shortcuts (`dws <service> +<command>`) share the same
// command tree; mergeTopLevelCommands folds each shortcut's service parent
// into the matching helper command so the `+leaf` sits alongside existing
// subcommands.
commands = append(commands, builtin.Commands()...)
return mergeTopLevelCommands(commands)
}
// pickCommands returns the union of dynamic and helpers commands. For
// same-named top-level products, helper-only leaves are grafted into the
// dynamic tree via cmdutil.MergeHardcodedLeaves so the discovery envelope
// remains the authority for leaves it declares, while hardcoded helpers can
// still fill gaps the envelope did not cover (e.g. `chat message send-by-bot`
// alongside the envelope's `chat message send`).
//
// Why this exists: mergeTopLevelCommands below calls cobracmd.MergeCommandTree
// on same-named top-level commands, which — at leaf conflicts — falls back to
// "more local flags wins" via ShouldReplaceLeaf. Hardcoded helpers commands
// typically expose more flags than the corresponding dynamic overlay leaves,
// so a naive append would silently promote helper leaves over their dynamic
// counterparts. MergeHardcodedLeaves avoids that by letting dynamic win every
// leaf conflict, and only adding subtrees the dynamic side lacks.
func pickCommands(dynamic, helpers []*cobra.Command) []*cobra.Command {
dynByName := make(map[string]*cobra.Command, len(dynamic))
out := make([]*cobra.Command, 0, len(dynamic)+len(helpers))
for _, c := range dynamic {
if c == nil {
continue
}
dynByName[c.Name()] = c
out = append(out, c)
}
for _, h := range helpers {
if h == nil {
continue
}
if dyn := dynByName[h.Name()]; dyn != nil {
cmdutil.MergeHardcodedLeaves(dyn, h)
continue
}
out = append(out, h)
}
return out
}
func injectStaticServers() {
hooks := edition.Get()
var servers []edition.ServerInfo
// injectStaticServers converts edition.ServerInfo entries into
// market.ServerDescriptor and feeds them into SetDynamicServers so the
// direct-runtime endpoint resolver can find them.
func injectStaticServers(servers []edition.ServerInfo) {
descriptors := make([]market.ServerDescriptor, 0, len(servers))
if fn := hooks.StaticServers; fn != nil {
servers = append(servers, fn()...)
}
if fn := hooks.SupplementServers; fn != nil {
servers = append(servers, fn()...)
}
if len(servers) == 0 {
return
}
descriptors := make([]mcptypes.ServerDescriptor, 0, len(servers))
for _, s := range servers {
descriptors = append(descriptors, market.ServerDescriptor{
descriptors = append(descriptors, mcptypes.ServerDescriptor{
Key: s.ID,
DisplayName: s.Name,
Endpoint: s.Endpoint,
CLI: market.CLIOverlay{
CLI: mcptypes.CLIOverlay{
ID: s.ID,
Command: s.ID,
Prefixes: s.Prefixes,
@@ -111,409 +76,6 @@ func injectStaticServers(servers []edition.ServerInfo) {
SetDynamicServers(descriptors)
}
// loadDynamicCommands loads the server registry and generates CLI commands
// dynamically from CLIOverlay metadata. It consults the disk cache first.
// Within the short revalidation window it uses the cached registry directly;
// after that it revalidates against the live market registry. Once the hard
// RegistryTTL expires, a successful live registry fetch triggers a full detail
// refresh for every server so command metadata cannot stay pinned to an
// arbitrarily old snapshot. On network failure with a stale cache, it
// gracefully degrades to the cached data so the CLI remains functional
// offline.
//
// Tests may override discoveryBaseURLOverride to redirect to a local server;
// in that case the registry cache is always bypassed.
// editionPartition returns the cache partition for the active edition.
// Thin wrapper around config.EditionPartition; kept so the many existing
// call sites in internal/app don't need to thread edition.Get() everywhere.
func editionPartition() string {
return config.EditionPartition(edition.Get().Name)
}
// discoveryTraceEnabled reports whether the user asked for discovery-path diagnostics.
// loadDynamicCommands runs while building the command tree, before PersistentPreRun
// applies --debug to slog; we also accept argv --debug and DWS_PERF_DEBUG for consistency.
func discoveryTraceEnabled() bool {
if IsPerfDebugEnabled() {
return true
}
for _, a := range os.Args[1:] {
if a == "--debug" {
return true
}
}
return false
}
func discoveryTraceServerIDs(servers []market.ServerDescriptor) []string {
seen := make(map[string]struct{})
for _, s := range servers {
id := strings.TrimSpace(s.CLI.Command)
if id == "" {
id = strings.TrimSpace(s.CLI.ID)
}
if id == "" {
continue
}
seen[id] = struct{}{}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Strings(out)
const maxIDs = 48
if len(out) > maxIDs {
out = out[:maxIDs]
}
return out
}
func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.Command {
store := cacheStoreFromEnv()
partition := editionPartition()
// Bypass the registry cache when a fixture override is active.
// This ensures tests that set DWS_CATALOG_FIXTURE always get fresh
// data from their local mock server without interference from a
// stale on-disk cache written by a previous production run.
useCache := strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) == ""
// --- Cache-first server registry ---
cacheLoadStart := time.Now()
snapshot, freshness, cacheErr := store.LoadRegistry(partition)
RecordTiming(ctx, "registry_cache", time.Since(cacheLoadStart))
var servers []market.ServerDescriptor
now := store.Now().UTC()
usingCachedRegistry := useCache && cacheErr == nil && len(snapshot.Servers) > 0
if usingCachedRegistry {
servers = snapshot.Servers
// Only trigger async revalidation in production (no URL override).
// Tests set discoveryBaseURLOverride and control cache expiry directly,
// so background revalidation would interfere with test expectations.
if discoveryBaseURLOverride == "" && (freshness == cache.FreshnessStale || cache.ShouldRevalidate(now, snapshot.SavedAt)) {
go asyncRevalidateRegistry(ctx, store, partition)
}
}
if len(servers) > 0 && discoveryTraceEnabled() {
slog.Info("loadDynamicCommands: skipping sync discovery fetch, using registry cache",
"partition", partition,
"servers", len(servers),
"registry_freshness", string(freshness))
}
// Cache miss or bypassed: fetch from market API synchronously (first run only).
if len(servers) == 0 {
if discoveryTraceEnabled() {
if edURL := strings.TrimSpace(edition.Get().DiscoveryURL); edURL != "" {
slog.Info("loadDynamicCommands: sync discovery fetch", "partition", partition, "url", edURL)
} else {
baseURL := cli.DefaultMarketBaseURL
if discoveryBaseURLOverride != "" {
baseURL = discoveryBaseURLOverride
}
slog.Info("loadDynamicCommands: sync market catalog fetch", "partition", partition, "base_url", baseURL)
}
}
fetchStart := time.Now()
resp, fetchErr := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
RecordTiming(ctx, "market_fetch", time.Since(fetchStart))
if fetchErr != nil {
if discoveryTraceEnabled() {
slog.Info("loadDynamicCommands: sync discovery fetch failed",
"partition", partition,
"error", fetchErr.Error())
}
slog.Debug("loadDynamicCommands: market API fetch failed", "error", fetchErr)
// Degrade to stale cache if available (production only).
if useCache && cacheErr == nil && len(snapshot.Servers) > 0 {
slog.Debug("loadDynamicCommands: degrading to stale registry cache", "servers", len(snapshot.Servers))
servers = snapshot.Servers
} else {
// no-op: fall through to FallbackServers check below
}
} else {
servers = market.NormalizeServers(resp, "market")
if discoveryTraceEnabled() {
slog.Info("loadDynamicCommands: sync discovery fetch ok",
"partition", partition,
"response_servers", len(resp.Servers),
"metadata_count", resp.Metadata.Count,
"normalized_servers", len(servers),
"cli_command_ids", discoveryTraceServerIDs(servers))
}
// Persist fresh data (only in non-test mode).
if useCache {
saveStart := time.Now()
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
slog.Debug("loadDynamicCommands: failed to save registry cache", "error", saveErr)
}
RecordTiming(ctx, "cache_save", time.Since(saveStart))
}
}
}
// FallbackServers: safety net when Market discovery + cache both fail.
if len(servers) == 0 {
if fn := edition.Get().FallbackServers; fn != nil {
if fb := fn(); len(fb) > 0 {
slog.Debug("loadDynamicCommands: using FallbackServers", "count", len(fb))
descriptors := editionmerge.FallbackToDescriptors(fb)
descriptors = editionmerge.MergeSupplement(descriptors)
SetDynamicServers(descriptors)
return nil
}
}
return nil
}
// Merge edition-specific supplement servers (not in Market).
servers = editionmerge.MergeSupplement(servers)
// Inject dynamic server data for endpoint resolution
SetDynamicServers(servers)
detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
RecordTiming(ctx, "build_commands", time.Since(buildStart))
return cmds
}
// loadCachedDetailsFast reads Detail API tool metadata from disk cache only —
// no network calls. Returns whatever is available (fresh or stale).
func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor) map[string][]market.DetailTool {
result := make(map[string][]market.DetailTool)
if store == nil {
return result
}
partition := editionPartition()
for _, server := range servers {
if server.DetailLocator.MCPID <= 0 {
continue
}
serverID := strings.TrimSpace(server.CLI.ID)
if serverID == "" {
continue
}
snap, _, err := store.LoadDetail(partition, serverID)
if err != nil {
continue
}
var payload struct {
Tools []market.DetailTool `json:"tools"`
}
if jsonErr := json.Unmarshal(snap.Payload, &payload); jsonErr == nil && len(payload.Tools) > 0 {
result[serverID] = payload.Tools
}
}
return result
}
// fetchDetailsByServerID fetches MCP Detail API tool metadata for each server
// with a known mcpId. Returns a map from CLI server ID → []DetailTool.
// Results are read from / written to the disk cache (DetailTTL=7d).
// All network fetches run concurrently; best-effort (errors silently skip).
func fetchDetailsByServerID(ctx context.Context, client *market.Client, servers []market.ServerDescriptor, store *cache.Store, forceRefresh bool) map[string][]market.DetailTool {
if ctx == nil {
ctx = context.Background()
}
partition := editionPartition()
now := time.Now().UTC()
if store != nil && store.Now != nil {
now = store.Now().UTC()
}
type entry struct {
id string
tools []market.DetailTool
}
results := make(chan entry, len(servers))
var wg sync.WaitGroup
for _, server := range servers {
mcpID := server.DetailLocator.MCPID
if mcpID <= 0 {
continue
}
serverID := strings.TrimSpace(server.CLI.ID)
if serverID == "" {
continue
}
wg.Add(1)
go func(srv market.ServerDescriptor, sID string, mID int) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
slog.Error("fetchDetailsByServerID: goroutine panicked", "server", sID, "panic", r)
}
}()
// Cache hit check. Fresh entries within the short revalidation window
// are returned immediately. Older entries still serve as fallback if
// the live market detail request fails.
var cachedTools []market.DetailTool
haveCachedTools := false
if store != nil {
if snap, freshness, err := store.LoadDetail(partition, sID); err == nil {
var payload struct {
Tools []market.DetailTool `json:"tools"`
}
if jsonErr := json.Unmarshal(snap.Payload, &payload); jsonErr == nil && len(payload.Tools) > 0 {
cachedTools = payload.Tools
haveCachedTools = true
}
if !forceRefresh && freshness == cache.FreshnessFresh && haveCachedTools && !cache.ShouldRevalidate(now, snap.SavedAt) {
slog.Debug("fetchDetailsByServerID: using cached detail", "id", sID)
results <- entry{id: sID, tools: cachedTools}
return
}
}
}
// Network fetch with per-server 5s timeout.
fetchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
var detail market.DetailResponse
var fetchErr error
detailURL := strings.TrimSpace(srv.DetailLocator.DetailURL)
if detailURL != "" {
detail, fetchErr = client.FetchDetailByURL(fetchCtx, detailURL)
} else {
detail, fetchErr = client.FetchDetail(fetchCtx, mID)
}
if fetchErr != nil {
slog.Debug("fetchDetailsByServerID: skipping server", "id", sID, "mcpId", mID, "error", fetchErr)
if haveCachedTools {
results <- entry{id: sID, tools: cachedTools}
}
return
}
if !detail.Success || len(detail.Result.Tools) == 0 {
if haveCachedTools {
results <- entry{id: sID, tools: cachedTools}
}
return
}
// Persist to cache.
if store != nil {
if payload, marshalErr := json.Marshal(map[string]any{"tools": detail.Result.Tools}); marshalErr == nil {
if saveErr := store.SaveDetail(partition, sID, cache.DetailSnapshot{
MCPID: mID,
Payload: payload,
}); saveErr != nil {
slog.Debug("fetchDetailsByServerID: failed to save detail cache", "id", sID, "error", saveErr)
}
}
}
slog.Debug("fetchDetailsByServerID: got tool details", "id", sID, "tools", len(detail.Result.Tools))
results <- entry{id: sID, tools: detail.Result.Tools}
}(server, serverID, mcpID)
}
// Close channel after all goroutines finish.
go func() {
wg.Wait()
close(results)
}()
result := make(map[string][]market.DetailTool)
for e := range results {
result[e.id] = e.tools
}
return result
}
// discoveryBaseURLOverride allows tests to redirect discovery to a local server.
// Must be empty in production; only set during test execution.
var discoveryBaseURLOverride string
// SetDiscoveryBaseURL sets the base URL used for dynamic server discovery.
// Intended for test use only.
func SetDiscoveryBaseURL(url string) {
discoveryBaseURLOverride = url
}
// DiscoveryBaseURL returns the effective base URL for discovery —
// discoveryBaseURLOverride if set, otherwise DefaultMarketBaseURL.
func DiscoveryBaseURL() string {
if discoveryBaseURLOverride != "" {
return discoveryBaseURLOverride
}
return cli.DefaultMarketBaseURL
}
// ipv4HTTPClient returns an HTTP client that forces IPv4 connections with
// the given total request timeout. This avoids IPv6 DNS/connect timeouts on
// hosts without IPv6 networking.
func ipv4HTTPClient(timeout time.Duration) *http.Client {
dialer := &net.Dialer{Timeout: 3 * time.Second}
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)
},
},
}
}
// ipv4OnlyHTTPClient returns an IPv4-forcing HTTP client with a short timeout
// suitable for CLI startup network requests.
func ipv4OnlyHTTPClient() *http.Client {
return ipv4HTTPClient(5 * time.Second)
}
// fetchRegistryServers performs the server-list HTTP fetch honoring the
// active edition's DiscoveryURL override. It is the single source of truth
// for all server-list fetches (startup, async revalidation, explicit
// `cache refresh`); keeping the edition-URL branch in one place prevents
// call sites from drifting out of sync.
func fetchRegistryServers(ctx context.Context, httpClient *http.Client) (market.ListResponse, error) {
if editionURL := strings.TrimSpace(edition.Get().DiscoveryURL); editionURL != "" {
client := market.NewClient("", httpClient)
if fn := edition.Get().DiscoveryHeaders; fn != nil {
client.Headers = fn()
}
return client.FetchServersFromURL(ctx, editionURL)
}
client := market.NewClient(DiscoveryBaseURL(), httpClient)
return client.FetchServers(ctx, config.DefaultFetchServersLimit)
}
// asyncRevalidateRegistry refreshes the registry cache in the background.
// Uses a short timeout derived from the parent context and silently ignores
// errors — the next CLI invocation will pick up the refreshed cache or retry.
func asyncRevalidateRegistry(parent context.Context, store *cache.Store, partition string) {
ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()
resp, err := fetchRegistryServers(ctx, ipv4OnlyHTTPClient())
if err != nil {
slog.Debug("asyncRevalidateRegistry: fetch failed", "error", err)
return
}
servers := market.NormalizeServers(resp, "market")
if saveErr := store.SaveRegistry(partition, cache.RegistrySnapshot{Servers: servers}); saveErr != nil {
slog.Debug("asyncRevalidateRegistry: save failed", "error", saveErr)
}
}
func newLegacyHiddenCommands(_ executor.Runner) []*cobra.Command {
return nil
}
@@ -544,8 +106,3 @@ func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
})
return out
}
// mergeSupplementServers / fallbackToDescriptors have moved to
// internal/editionmerge so that both internal/cli and internal/app can
// apply the edition's SupplementServers / FallbackServers hooks against
// the same discovery pipeline (command tree + runtime catalog).
-743
View File
@@ -1,743 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
// marketListResponse builds a minimal valid FetchServers JSON response.
// The server has a ToolOverride so BuildDynamicCommands emits a command.
func marketListResponse(cliID string) map[string]any {
return map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
map[string]any{
"server": map[string]any{
"name": "Test Server",
"description": "desc",
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/test/v1",
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active", "isLatest": true,
},
"com.dingtalk.mcp.registry/cli": map[string]any{
"id": cliID,
"command": cliID,
"toolOverrides": map[string]any{
"test_tool": map[string]any{
"cliName": "test",
"flags": map[string]any{},
},
},
},
},
},
},
}
}
type testCLIServerSpec struct {
id string
command string
tool string
cliName string
}
func marketListResponseForSpecs(specs ...testCLIServerSpec) map[string]any {
servers := make([]any, 0, len(specs))
for _, spec := range specs {
servers = append(servers, map[string]any{
"server": map[string]any{
"name": spec.command,
"description": spec.command + " desc",
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/" + spec.command + "/v1",
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active", "isLatest": true,
},
"com.dingtalk.mcp.registry/cli": map[string]any{
"id": spec.id,
"command": spec.command,
"toolOverrides": map[string]any{
spec.tool: map[string]any{
"cliName": spec.cliName,
"flags": map[string]any{},
},
},
},
},
})
}
return map[string]any{
"metadata": map[string]any{"count": len(servers), "nextCursor": ""},
"servers": servers,
}
}
// minimalCLIServer returns a ServerDescriptor with ToolOverrides so
// BuildDynamicCommands will emit at least one cobra command.
func minimalCLIServer(id, endpoint string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: id + "-key",
DisplayName: id,
Endpoint: endpoint,
Source: "market",
CLI: market.CLIOverlay{
ID: id,
Command: id,
ToolOverrides: map[string]market.CLIToolOverride{
"test_tool": {CLIName: "test"},
},
},
HasCLIMeta: true,
}
}
// TestLoadDynamicCommandsUsesFreshCacheWithoutNetwork verifies that when a
// fresh registry cache exists, no network request is made.
//
// This test uses an isolated DWS_CACHE_DIR + discoveryBaseURLOverride so that:
// - useCache=true (DWS_CATALOG_FIXTURE is "")
// - The test server records any incoming request; it should NOT be hit when cache is fresh.
func TestLoadDynamicCommandsUsesFreshCacheWithoutNetwork(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
requestCount := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
_ = json.NewEncoder(w).Encode(marketListResponse("test-fresh"))
}))
defer srv.Close()
// Isolated cache dir with a FRESH snapshot.
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC(), // fresh
Servers: []market.ServerDescriptor{minimalCLIServer("cached", "https://mcp.dingtalk.com/cached/v1")},
})
if err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
// Point discovery to the test server. Since cache is fresh and
// useCache=true (CATALOG_FIXTURE is ""), the network should not be needed.
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
if got := requestCount.Load(); got != 0 {
t.Errorf("network request count = %d, want 0 (fresh cache should be used)", got)
}
if len(cmds) == 0 {
t.Errorf("loadDynamicCommands() returned 0 commands, want >0 from fresh cache")
}
}
// TestLoadDynamicCommandsUsesStaleCacheOnStartup verifies that when the
// registry cache is stale, startup still returns commands from the cache
// instead of blocking on a synchronous market refresh.
func TestLoadDynamicCommandsUsesStaleCacheOnStartup(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
requestCount := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
_ = json.NewEncoder(w).Encode(marketListResponse("network-server"))
}))
defer srv.Close()
// Isolated cache dir with a STALE snapshot.
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-25 * time.Hour), // older than RegistryTTL=24h
Servers: []market.ServerDescriptor{minimalCLIServer("stale", "https://mcp.dingtalk.com/stale/v1")},
})
if err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
if len(cmds) == 0 {
t.Fatalf("loadDynamicCommands() = 0 commands, want >0 from stale cache")
}
if got := requestCount.Load(); got != 0 {
t.Errorf("startup network request count = %d, want 0 (stale cache should not block startup)", got)
}
}
// TestLoadDynamicCommandsCacheUpdatedAfterFetch verifies the cache is persisted
// after a successful network fetch (useCache=true, isolated cache dir).
func TestLoadDynamicCommandsCacheUpdatedAfterFetch(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(marketListResponse("fresh-server"))
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
SetDiscoveryBaseURL(srv.URL) // stale/empty cache → network
t.Cleanup(func() { SetDiscoveryBaseURL("") })
_ = loadDynamicCommands(context.Background(), nil)
snapshot, freshness, err := store.LoadRegistry("default/default")
if err != nil {
t.Fatalf("LoadRegistry() after fetch error = %v", err)
}
if freshness != cache.FreshnessFresh {
t.Errorf("cache freshness = %s, want fresh", freshness)
}
if len(snapshot.Servers) == 0 {
t.Errorf("cache servers = 0, want >0 after network fetch")
}
}
// TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError verifies that
// when the market API is unavailable but a stale cache exists, the CLI
// still generates commands from the stale data (offline degradation).
func TestLoadDynamicCommandsFallsBackToStaleCacheOnNetworkError(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError)
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-25 * time.Hour), // stale
Servers: []market.ServerDescriptor{minimalCLIServer("degraded", "https://mcp.dingtalk.com/degraded/v1")},
})
if err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
if len(cmds) == 0 {
t.Errorf("loadDynamicCommands() = 0 commands, want >0 (stale fallback on network error)")
}
}
func TestLoadDynamicCommandsRefreshesRegistryCacheInBackgroundAfterAgedStart(t *testing.T) {
// Skip: async revalidation is disabled when discoveryBaseURLOverride is set.
// This test requires background refresh which only runs in production mode.
t.Skip("async revalidation disabled in test mode")
t.Setenv(cli.CatalogFixtureEnv, "")
var phase atomic.Int32
phase.Store(1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload := marketListResponseForSpecs(testCLIServerSpec{
id: "doc",
command: "doc",
tool: "create_document",
cliName: "create-document",
})
if phase.Load() == 2 {
payload = marketListResponseForSpecs(
testCLIServerSpec{
id: "doc",
command: "doc",
tool: "archive_document",
cliName: "archive-document",
},
testCLIServerSpec{
id: "drive",
command: "drive",
tool: "list_files",
cliName: "list-files",
},
)
}
_ = json.NewEncoder(w).Encode(payload)
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
snapshot, _, err := store.LoadRegistry("default/default")
if err != nil {
t.Fatalf("LoadRegistry() error = %v", err)
}
snapshot.SavedAt = time.Now().UTC().Add(-2 * time.Hour)
if err := store.SaveRegistry("default/default", snapshot); err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
phase.Store(2)
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
refreshed, _, err := store.LoadRegistry("default/default")
if err == nil && len(refreshed.Servers) == 2 {
break
}
time.Sleep(20 * time.Millisecond)
}
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"archive-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
}
func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadata(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
var phase atomic.Int32
docDetailCalls := new(atomic.Int32)
driveDetailCalls := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis":
payload := map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
registryServerEnvelope("doc", "doc", "2026-03-21T02:00:00Z", 1001, "create_document", "create-document"),
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
},
}
if phase.Load() == 1 {
payload["servers"] = []any{
registryServerEnvelope("doc", "doc", "2026-03-25T10:00:00Z", 1001, "archive_document", "archive-document"),
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
}
}
_ = json.NewEncoder(w).Encode(payload)
case r.URL.Path == "/mcp/market/detail":
switch r.URL.Query().Get("mcpId") {
case "1001":
docDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1001, "archive_document", "Archive Document", "archive desc"))
case "1002":
driveDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1002, "list_files", "List Files", "list desc"))
default:
http.Error(w, "unknown mcpId", http.StatusNotFound)
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after startup = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after startup = %d, want 0", got)
}
phase.Store(1)
docDetailCalls.Store(0)
driveDetailCalls.Store(0)
ageCacheSnapshotsOnDisk(t, cacheDir, time.Now().UTC().Add(-2*time.Hour))
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after aged startup = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after aged startup = %d, want 0", got)
}
}
func TestLoadDynamicCommandsDoesNotSynchronouslyFetchDetailMetadataWhenRegistryTTLExpires(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
docDetailCalls := new(atomic.Int32)
driveDetailCalls := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/cli/discovery/apis":
_ = json.NewEncoder(w).Encode(map[string]any{
"metadata": map[string]any{"count": 2, "nextCursor": ""},
"servers": []any{
registryServerEnvelope("doc", "doc", "2026-03-21T02:00:00Z", 1001, "create_document", "create-document"),
registryServerEnvelope("drive", "drive", "2026-03-21T02:00:00Z", 1002, "list_files", "list-files"),
},
})
case r.URL.Path == "/mcp/market/detail":
switch r.URL.Query().Get("mcpId") {
case "1001":
docDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1001, "create_document", "Create Document", "create desc"))
case "1002":
driveDetailCalls.Add(1)
_ = json.NewEncoder(w).Encode(detailResponse(1002, "list_files", "List Files", "list desc"))
default:
http.Error(w, "unknown mcpId", http.StatusNotFound)
}
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmds := loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after startup = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after startup = %d, want 0", got)
}
docDetailCalls.Store(0)
driveDetailCalls.Store(0)
ageCacheSnapshotsOnDisk(t, cacheDir, time.Now().UTC().Add(-25*time.Hour))
cmds = loadDynamicCommands(context.Background(), nil)
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
assertDynamicCommandChildren(t, cmds, "drive", []string{"list-files"})
if got := docDetailCalls.Load(); got != 0 {
t.Fatalf("doc detail calls after registry TTL expiry = %d, want 0", got)
}
if got := driveDetailCalls.Load(); got != 0 {
t.Fatalf("drive detail calls after registry TTL expiry = %d, want 0", got)
}
}
func TestLoadDynamicCommandsUsesStaleCacheWithoutBlockingRegistryRefresh(t *testing.T) {
t.Setenv(cli.CatalogFixtureEnv, "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
_ = json.NewEncoder(w).Encode(marketListResponseForSpecs(testCLIServerSpec{
id: "doc",
command: "doc",
tool: "archive_document",
cliName: "archive-document",
}))
}))
defer srv.Close()
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
if err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-25 * time.Hour),
Servers: []market.ServerDescriptor{
{
Key: "doc-key",
DisplayName: "doc",
Endpoint: "https://mcp.dingtalk.com/doc/v1",
Source: "market",
CLI: market.CLIOverlay{
ID: "doc",
Command: "doc",
ToolOverrides: map[string]market.CLIToolOverride{
"create_document": {CLIName: "create-document"},
},
},
HasCLIMeta: true,
},
},
}); err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
start := time.Now()
cmds := loadDynamicCommands(context.Background(), nil)
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
t.Fatalf("loadDynamicCommands() took %v, want stale cache startup under 200ms", elapsed)
}
assertDynamicCommandChildren(t, cmds, "doc", []string{"create-document"})
}
// TestFetchDetailsByServerIDRunsConcurrently verifies that detail fetches are
// concurrent, not serial. Uses MCPID path to avoid the localhost SSRF guard.
func TestFetchDetailsByServerIDRunsConcurrently(t *testing.T) {
const numServers = 4
const delay = 50 * time.Millisecond
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(delay)
_ = json.NewEncoder(w).Encode(map[string]any{
"success": true,
"result": map[string]any{
"mcpId": 1, "name": "test", "description": "test",
"tools": []any{
map[string]any{"toolName": "test_tool", "toolTitle": "Test Tool", "toolDesc": "desc"},
},
},
})
}))
defer srv.Close()
servers := make([]market.ServerDescriptor, numServers)
for i := range servers {
servers[i] = market.ServerDescriptor{
DetailLocator: market.DetailLocator{MCPID: i + 1},
CLI: market.CLIOverlay{ID: "test-server-" + string(rune('a'+i))},
HasCLIMeta: true,
}
}
start := time.Now()
result := fetchDetailsByServerID(context.TODO(), market.NewClient(srv.URL, nil), servers, cache.NewStore(t.TempDir()), false)
elapsed := time.Since(start)
serialBound := time.Duration(numServers) * delay
if elapsed >= serialBound {
t.Errorf("elapsed %v >= serial bound %v: requests appear serial, want concurrent", elapsed, serialBound)
}
if len(result) == 0 {
t.Errorf("fetchDetailsByServerID() = empty map, want results")
}
}
func assertDynamicCommandChildren(t *testing.T, cmds []*cobra.Command, name string, want []string) {
t.Helper()
for _, cmd := range cmds {
if cmd.Name() != name {
continue
}
got := make([]string, 0)
for _, child := range cmd.Commands() {
if child.Name() == "help" {
continue
}
got = append(got, child.Name())
}
sort.Strings(got)
sortedWant := append([]string(nil), want...)
sort.Strings(sortedWant)
if len(got) != len(sortedWant) {
t.Fatalf("command %q children = %#v, want %#v", name, got, sortedWant)
}
for idx := range got {
if got[idx] != sortedWant[idx] {
t.Fatalf("command %q children = %#v, want %#v", name, got, sortedWant)
}
}
return
}
t.Fatalf("command %q not found", name)
}
func registryServerEnvelope(id, command, updatedAt string, mcpID int, toolName, cliName string) map[string]any {
return map[string]any{
"server": map[string]any{
"name": command,
"description": command + " desc",
"remotes": []any{
map[string]any{
"type": "streamable-http",
"url": "https://mcp.dingtalk.com/" + command + "/v1",
},
},
},
"_meta": map[string]any{
"com.dingtalk.mcp.registry/metadata": map[string]any{
"status": "active",
"isLatest": true,
"updatedAt": updatedAt,
"publishedAt": updatedAt,
"mcpId": mcpID,
},
"com.dingtalk.mcp.registry/cli": map[string]any{
"id": id,
"command": command,
"toolOverrides": map[string]any{
toolName: map[string]any{
"cliName": cliName,
"flags": map[string]any{},
},
},
},
},
}
}
func detailResponse(mcpID int, toolName, title, desc string) map[string]any {
return map[string]any{
"success": true,
"result": map[string]any{
"mcpId": mcpID,
"name": title,
"description": desc,
"tools": []any{
map[string]any{
"toolName": toolName,
"toolTitle": title,
"toolDesc": desc,
"toolRequest": `{"type":"object"}`,
"toolResponse": `{"type":"object"}`,
"actionVersion": "v1",
},
},
},
}
}
func ageCacheSnapshotsOnDisk(t *testing.T, root string, savedAt time.Time) {
t.Helper()
walkErr := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".json") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
return nil
}
if _, ok := payload["saved_at"]; !ok {
return nil
}
payload["saved_at"] = savedAt.Format(time.RFC3339Nano)
rewritten, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, rewritten, 0o644)
})
if walkErr != nil {
t.Fatalf("ageCacheSnapshotsOnDisk() error = %v", walkErr)
}
}
// TestFetchDetailsByServerIDUsesCacheOnHit verifies that a fresh detail cache
// entry prevents any network request.
func TestFetchDetailsByServerIDUsesCacheOnHit(t *testing.T) {
requestCount := new(atomic.Int32)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": map[string]any{"tools": []any{}}})
}))
defer srv.Close()
store := cache.NewStore(t.TempDir())
cachedTools := []market.DetailTool{{ToolName: "cached_tool", ToolTitle: "Cached", ToolDesc: "from cache"}}
cachedJSON, _ := json.Marshal(map[string]any{"tools": cachedTools})
err := store.SaveDetail("default/default", "test-server", cache.DetailSnapshot{
SavedAt: time.Now().UTC(),
MCPID: 42,
Payload: cachedJSON,
})
if err != nil {
t.Fatalf("SaveDetail() error = %v", err)
}
servers := []market.ServerDescriptor{
{DetailLocator: market.DetailLocator{MCPID: 42}, CLI: market.CLIOverlay{ID: "test-server"}, HasCLIMeta: true},
}
result := fetchDetailsByServerID(context.TODO(), market.NewClient(srv.URL, nil), servers, store, false)
if got := requestCount.Load(); got != 0 {
t.Errorf("network request count = %d, want 0 (fresh detail cache should be used)", got)
}
if len(result) == 0 {
t.Errorf("fetchDetailsByServerID() returned empty map, want cached tools")
}
}
-219
View File
@@ -1,219 +0,0 @@
// 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 (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
"github.com/spf13/cobra"
)
// TestPickCommands_DynamicWinsLeafConflicts verifies that when the discovery
// envelope produces a dynamic leaf and a helper registers the same-named leaf,
// the dynamic one wins — envelopes remain the runtime authority for behaviour
// they declare. The helper subtree must not slip in via
// mergeTopLevelCommands's LocalFlagCount-based arbitration.
func TestPickCommands_DynamicWinsLeafConflicts(t *testing.T) {
dynTask := &cobra.Command{Use: "task", Short: "dynamic-task", Run: func(*cobra.Command, []string) {}}
dyn := &cobra.Command{Use: "todo", Short: "dynamic"}
dyn.AddCommand(dynTask)
dynamic := []*cobra.Command{dyn}
hlpTask := &cobra.Command{Use: "task", Short: "helper-task", Run: func(*cobra.Command, []string) {}}
hlp := &cobra.Command{Use: "todo", Short: "helper"}
hlp.AddCommand(hlpTask)
helpers := []*cobra.Command{hlp}
got := pickCommands(dynamic, helpers)
if len(got) != 1 || got[0] != dyn {
t.Fatalf("pickCommands returned %v, want [dyn]", got)
}
// The dynamic leaf must still be the one we find under the top-level name.
var found *cobra.Command
for _, c := range got[0].Commands() {
if c.Name() == "task" {
found = c
}
}
if found != dynTask {
t.Fatalf("leaf conflict resolved to helper; want dynamic to win")
}
}
// TestPickCommands_HelperOnlyLeavesAreGrafted verifies that when a helper
// registers siblings the discovery envelope did NOT declare (e.g.
// `chat message send-by-bot`, `chat message recall-by-bot` next to the
// envelope's `chat message send`), those helper-only leaves are grafted into
// the dynamic subtree instead of being dropped. This is a regression guard:
// prior to this fix, pickCommands silently dropped the entire helper subtree
// whenever the top-level product name collided, which disappeared every
// helper-only leaf the envelope didn't cover.
func TestPickCommands_HelperOnlyLeavesAreGrafted(t *testing.T) {
dynMessage := &cobra.Command{Use: "message"}
dynMessage.AddCommand(&cobra.Command{Use: "send", Run: func(*cobra.Command, []string) {}})
dyn := &cobra.Command{Use: "chat"}
dyn.AddCommand(dynMessage)
dynamic := []*cobra.Command{dyn}
helperOnlyLeaf := &cobra.Command{Use: "send-by-bot", Run: func(*cobra.Command, []string) {}}
hlpMessage := &cobra.Command{Use: "message"}
hlpMessage.AddCommand(helperOnlyLeaf)
hlp := &cobra.Command{Use: "chat"}
hlp.AddCommand(hlpMessage)
helpers := []*cobra.Command{hlp}
got := pickCommands(dynamic, helpers)
if len(got) != 1 || got[0] != dyn {
t.Fatalf("pickCommands returned %v, want [dyn]", got)
}
var grafted *cobra.Command
for _, child := range dynMessage.Commands() {
if child.Name() == "send-by-bot" {
grafted = child
}
}
if grafted == nil {
t.Fatalf("helper-only leaf send-by-bot was not grafted into dynamic.chat.message")
}
if grafted != helperOnlyLeaf {
t.Fatalf("grafted leaf identity differs from helper-registered leaf")
}
}
// TestPickCommands_HelpersFillUncoveredProducts verifies that helpers whose
// names are NOT in the dynamic set are preserved — the dynamic overlay only
// shadows products it actually covers.
func TestPickCommands_HelpersFillUncoveredProducts(t *testing.T) {
dyn := &cobra.Command{Use: "todo"}
dynamic := []*cobra.Command{dyn}
todoHelper := &cobra.Command{Use: "todo"}
attendanceHelper := &cobra.Command{Use: "attendance"}
chatHelper := &cobra.Command{Use: "chat"}
helpers := []*cobra.Command{todoHelper, attendanceHelper, chatHelper}
got := pickCommands(dynamic, helpers)
names := make(map[string]*cobra.Command, len(got))
for _, c := range got {
names[c.Name()] = c
}
if names["todo"] != dyn {
t.Fatalf("todo = %v, want dynamic", names["todo"])
}
if names["attendance"] != attendanceHelper {
t.Fatalf("attendance not preserved from helpers")
}
if names["chat"] != chatHelper {
t.Fatalf("chat not preserved from helpers")
}
if len(got) != 3 {
t.Fatalf("got %d commands, want 3 (todo+attendance+chat)", len(got))
}
}
// TestPickCommands_EmptyDynamicPreservesHelpers verifies the degenerate case:
// when discovery returns nothing, helpers are the sole source of truth — the
// behaviour must be identical to the pre-refactor append-all code path.
func TestPickCommands_EmptyDynamicPreservesHelpers(t *testing.T) {
todoHelper := &cobra.Command{Use: "todo"}
chatHelper := &cobra.Command{Use: "chat"}
helpers := []*cobra.Command{todoHelper, chatHelper}
got := pickCommands(nil, helpers)
if len(got) != 2 {
t.Fatalf("got %d commands, want 2", len(got))
}
if got[0] != todoHelper || got[1] != chatHelper {
t.Fatalf("pickCommands changed helpers order or identity")
}
}
// TestPickCommands_HelperGroupShadowsDynamicLeaf simulates the issue #164
// shape mismatch: the discovery envelope publishes `chat group members` as
// a LEAF (the get_group_members tool exposed at that CLI path), while the
// hardcoded helper has restructured `members` into a GROUP container with
// `list / add / remove / add-bot` subcommands. The helper group carries the
// preferLegacyLeaf priority annotation, so it must replace the dynamic leaf
// and surface its subtree — otherwise `dws chat group members list` is
// unreachable and the user-visible regression in #164 stays.
func TestPickCommands_HelperGroupShadowsDynamicLeaf(t *testing.T) {
dynMembers := &cobra.Command{Use: "members", Run: func(*cobra.Command, []string) {}}
dynMembers.Flags().String("id", "", "")
dynGroup := &cobra.Command{Use: "group"}
dynGroup.AddCommand(dynMembers)
dyn := &cobra.Command{Use: "chat"}
dyn.AddCommand(dynGroup)
hlpList := &cobra.Command{Use: "list", Run: func(*cobra.Command, []string) {}}
hlpList.Flags().String("id", "", "")
hlpAdd := &cobra.Command{Use: "add", Run: func(*cobra.Command, []string) {}}
hlpRemove := &cobra.Command{Use: "remove", Run: func(*cobra.Command, []string) {}}
hlpMembers := &cobra.Command{Use: "members"}
hlpMembers.AddCommand(hlpList, hlpAdd, hlpRemove)
cobracmd.SetOverridePriority(hlpMembers, 100)
hlpGroup := &cobra.Command{Use: "group"}
hlpGroup.AddCommand(hlpMembers)
hlp := &cobra.Command{Use: "chat"}
hlp.AddCommand(hlpGroup)
got := pickCommands([]*cobra.Command{dyn}, []*cobra.Command{hlp})
if len(got) != 1 || got[0] != dyn {
t.Fatalf("got %v, want [dyn]", got)
}
// Locate the (potentially replaced) members node under chat.group.
var members *cobra.Command
for _, c := range dynGroup.Commands() {
if c.Name() == "members" {
members = c
break
}
}
if members == nil {
t.Fatalf("members node missing under dyn.chat.group after merge")
}
want := map[string]bool{"list": false, "add": false, "remove": false}
for _, sub := range members.Commands() {
if _, ok := want[sub.Name()]; ok {
want[sub.Name()] = true
}
}
for name, seen := range want {
if !seen {
t.Errorf("expected `chat group members %s` after merge, missing", name)
}
}
}
// TestPickCommands_NilsAreSkipped guards against nil entries sneaking in from
// a misbehaving factory.
func TestPickCommands_NilsAreSkipped(t *testing.T) {
dyn := &cobra.Command{Use: "todo"}
hlp := &cobra.Command{Use: "chat"}
got := pickCommands([]*cobra.Command{nil, dyn}, []*cobra.Command{nil, hlp})
if len(got) != 2 {
t.Fatalf("got %d commands, want 2 (nils filtered)", len(got))
}
if got[0] != dyn || got[1] != hlp {
t.Fatalf("unexpected ordering or identity after nil filter")
}
}
@@ -1,64 +0,0 @@
// 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 (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
// TestEditionPartition_SingleSourceOfTruth is the regression test that
// specifically targets the original bug: internal/app.loadDynamicCommands
// was computing its partition one way (editionPartition() →
// "wukong/default") while internal/cli.EnvironmentLoader was hardcoding
// config.DefaultPartition ("default/default"). This meant runtime endpoint
// resolution and command-tree generation read different cache files, and
// under gray-release the two partitions carried disjoint product lists —
// the historical root cause of `dws conference meeting create` failing
// while `dws todo task list` succeeded on the same host.
//
// Keeping both sides funneled through config.EditionPartition is the
// central invariant the fix enforces. If this test ever regresses, the
// two-partition split almost certainly came back.
func TestEditionPartition_SingleSourceOfTruth(t *testing.T) {
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
cases := []struct {
name string
edition string
want string
}{
{"open edition falls through to default/default", "", config.DefaultPartition},
{"explicit open edition remains default", "open", config.DefaultPartition},
{"wukong overlay uses wukong/default", "wukong", "wukong/default"},
{"custom edition is namespaced", "internal-lab", "internal-lab/default"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
edition.Override(&edition.Hooks{Name: tc.edition})
legacy := editionPartition()
shared := config.EditionPartition(edition.Get().Name)
if legacy != shared {
t.Fatalf("editionPartition()=%q, config.EditionPartition()=%q — partition split regressed for edition %q", legacy, shared, tc.edition)
}
if legacy != tc.want {
t.Fatalf("editionPartition()=%q, want %q for edition %q", legacy, tc.want, tc.edition)
}
})
}
}
+148
View File
@@ -0,0 +1,148 @@
package app
import (
"context"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
)
func TestRuntimeRunnerAggregatesCommaSeparatedProfiles(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_a"),
authLogoutTestToken("corp_b"),
)
authpkg.SetRuntimeProfile("corp_a, corp_b")
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
result, err := runner.Run(context.Background(), executor.Invocation{
Kind: "helper_invocation",
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
Params: map[string]any{"limit": 10},
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if got := authpkg.RuntimeProfile(); got != "corp_a, corp_b" {
t.Fatalf("runtime profile after Run = %q, want restored raw selector", got)
}
content := result.Response["content"].(map[string]any)
if content["multiProfile"] != true {
t.Fatalf("multiProfile = %#v, want true", content["multiProfile"])
}
if content["success"] != true {
t.Fatalf("success = %#v, want true", content["success"])
}
profiles := content["profiles"].([]any)
if len(profiles) != 2 {
t.Fatalf("profiles len = %d, want 2", len(profiles))
}
for i, wantCorpID := range []string{"corp_a", "corp_b"} {
entry := profiles[i].(map[string]any)
if entry["corpId"] != wantCorpID {
t.Fatalf("profiles[%d].corpId = %#v, want %q", i, entry["corpId"], wantCorpID)
}
if entry["ok"] != true {
t.Fatalf("profiles[%d].ok = %#v, want true", i, entry["ok"])
}
resultPayload := entry["result"].(map[string]any)
if resultPayload["runtimeProfile"] != wantCorpID {
t.Fatalf("profiles[%d].result.runtimeProfile = %#v, want %q", i, resultPayload["runtimeProfile"], wantCorpID)
}
}
}
func TestRuntimeRunnerDeduplicatesCommaSeparatedProfilesByCorpID(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
authpkg.SetRuntimeProfile("corp_a, corp_a org,corp_b")
selections, multi, err := resolveMultiProfileSelections(configDir, authpkg.RuntimeProfile())
if err != nil {
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
}
if !multi {
t.Fatal("multi = false, want true")
}
if len(selections) != 2 {
t.Fatalf("selections len = %d, want 2", len(selections))
}
if selections[0].Profile.CorpID != "corp_a" || selections[1].Profile.CorpID != "corp_b" {
t.Fatalf("resolved corp IDs = %q, %q; want corp_a, corp_b", selections[0].Profile.CorpID, selections[1].Profile.CorpID)
}
}
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
authpkg.SetRuntimeProfile("corp_a")
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
result, err := runner.Run(context.Background(), executor.Invocation{
Kind: "helper_invocation",
CanonicalProduct: "contact",
Tool: "get_current_user_profile",
})
if err != nil {
t.Fatalf("Run() error = %v", err)
}
if _, ok := result.Response["content"].(map[string]any)["multiProfile"]; ok {
t.Fatalf("single profile unexpectedly returned aggregate content: %#v", result.Response)
}
if got := authpkg.RuntimeProfile(); got != "corp_a" {
t.Fatalf("runtime profile after Run = %q, want corp_a", got)
}
}
func TestCommaNamedProfileStillResolvesAsSingleProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_comma"), authLogoutTestToken("corp_other"))
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == "corp_comma" {
cfg.Profiles[i].Name = "alpha,beta"
}
}
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
t.Fatalf("SaveProfiles() error = %v", err)
}
selections, multi, err := resolveMultiProfileSelections(configDir, "alpha,beta")
if err != nil {
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
}
if multi {
t.Fatalf("multi = true, want false; selections=%#v", selections)
}
}
func TestCommaSeparatedProfileRejectsEmptySelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
_, _, err := resolveMultiProfileSelections(configDir, "corp_a,,corp_b")
if err == nil {
t.Fatal("resolveMultiProfileSelections() error = nil, want validation error")
}
if !strings.Contains(err.Error(), "empty profile selector") {
t.Fatalf("error = %q, want empty profile selector", err.Error())
}
}
type multiProfileFallbackRunner struct{}
func (multiProfileFallbackRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": map[string]any{
"runtimeProfile": authpkg.RuntimeProfile(),
"tool": invocation.Tool,
},
},
}, nil
}
+83
View File
@@ -0,0 +1,83 @@
package app
import (
"bytes"
"os"
"path/filepath"
"testing"
)
// writeMultiSkillSrc creates a fake multi skill source tree with the given
// subdir names, each containing a minimal SKILL.md.
func writeMultiSkillSrc(t *testing.T, names ...string) string {
t.Helper()
src := t.TempDir()
for _, n := range names {
dir := filepath.Join(src, n)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+n+"\n"), 0o644); err != nil {
t.Fatal(err)
}
}
return src
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
// dws-shared must ship even when --skill narrows the set to a single product.
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
if !contains(all, "dws-shared") {
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
}
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
if err != nil {
t.Fatal(err)
}
if contains(filtered, "dws-shared") {
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
}
final := ensureMandatorySharedSkill(filtered, all)
if !contains(final, "dws-shared") {
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
}
// Actually install with the filtered+mandatory set and assert dws-shared landed.
dest := t.TempDir()
var out, errOut bytes.Buffer
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
t.Fatalf("install: %v (%s)", err, errOut.String())
}
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
}
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
t.Fatalf("dingtalk-aitable not installed: %v", err)
}
}
// When the source has no dws-shared (older layout), nothing is forced.
func TestP1SharedNoopWhenAbsent(t *testing.T) {
src := writeMultiSkillSrc(t, "dingtalk-aitable")
all, err := listMultiSkillNames(src)
if err != nil {
t.Fatal(err)
}
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
if contains(final, "dws-shared") {
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
}
}
+236 -81
View File
@@ -14,6 +14,7 @@
package app
import (
"bytes"
"context"
"encoding/json"
stderrors "errors"
@@ -32,8 +33,9 @@ import (
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
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/jsonutil"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
"github.com/fatih/color"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
)
const (
@@ -50,6 +52,10 @@ const (
var openBrowserFunc = tryOpenBrowser
type patSuppressBrowserOpenKeyType struct{}
var patSuppressBrowserOpenKey = patSuppressBrowserOpenKeyType{}
// PatScopeError holds information about a missing PAT scope.
type PatScopeError struct {
OriginalError string
@@ -152,36 +158,31 @@ func extractPatScopeError(err error) *PatScopeError {
// PrintPatAuthError prints a human-readable PAT authorization error.
func PrintPatAuthError(w io.Writer, scopeErr *PatScopeError) {
bold := color.New(color.Bold).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
fmt.Fprintln(w)
fmt.Fprintf(w, "{\n")
fmt.Fprintf(w, " %s: %s,\n", bold("\"ok\""), "false")
fmt.Fprintf(w, " %s: %q,\n", bold("\"identity\""), scopeErr.Identity)
fmt.Fprintf(w, " %s: {\n", bold("\"error\""))
fmt.Fprintf(w, " %s: %q,\n", bold("\"type\""), scopeErr.ErrorType)
fmt.Fprintf(w, " %s: %q,\n", bold("\"message\""), scopeErr.Message)
fmt.Fprintf(w, " %s: %q\n", bold("\"hint\""), scopeErr.Hint)
fmt.Fprintf(w, " %s: %s,\n", tui.Bold("\"ok\""), "false")
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"identity\""), scopeErr.Identity)
fmt.Fprintf(w, " %s: {\n", tui.Bold("\"error\""))
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"type\""), scopeErr.ErrorType)
fmt.Fprintf(w, " %s: %q,\n", tui.Bold("\"message\""), scopeErr.Message)
fmt.Fprintf(w, " %s: %q\n", tui.Bold("\"hint\""), scopeErr.Hint)
fmt.Fprintf(w, " }\n")
fmt.Fprintf(w, "}\n")
fmt.Fprintln(w)
// Print authorization instructions
fmt.Fprintf(w, "%s %s\n", green("▶"), bold("需要额外授权"))
fmt.Fprintf(w, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要额外授权"))
fmt.Fprintln(w)
fmt.Fprintf(w, " %s %s\n", dim("#"), dim("运行以下命令完成授权"))
fmt.Fprintf(w, " %s %s\n", tui.Dim("#"), tui.Dim("运行以下命令完成授权"))
if scopeErr.MissingScope != "" {
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan(fmt.Sprintf("dws auth login --scope %q", scopeErr.MissingScope)))
} else {
fmt.Fprintf(w, " %s %s\n", cyan("$"), cyan("dws auth login"))
fmt.Fprintf(w, " %s %s\n", tui.Cyan("$"), tui.Cyan("dws auth login"))
}
fmt.Fprintln(w)
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", dim("ℹ"))
fmt.Fprintf(w, " %s 在浏览器中打开授权链接,完成授权后重新执行命令\n", tui.Dim("ℹ"))
fmt.Fprintln(w)
}
@@ -205,7 +206,10 @@ func wantsStructuredPATOutputFromRunner(runner executor.Runner) bool {
return wantsStructuredPATOutput(rr)
}
func currentPATOpenBrowser(configDir string) bool {
func currentPATOpenBrowser(ctx context.Context, configDir string) bool {
if suppressed, _ := ctx.Value(patSuppressBrowserOpenKey).(bool); suppressed {
return false
}
return pat.EffectiveOpenBrowser(configDir)
}
@@ -224,24 +228,34 @@ func enrichPATErrorWithOpenBrowser(raw string, openBrowser bool) string {
data = map[string]any{}
payload["data"] = data
}
if rawURI := patAuthorizationURIFromData(data); rawURI != "" {
authURL := apperrors.PATAuthorizationURL(rawURI)
data["uri"] = authURL
delete(data, "authUrl")
delete(data, "authorizationUrl")
}
data["openBrowser"] = openBrowser
encoded, err := json.Marshal(payload)
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
if err != nil {
return raw
}
return string(encoded)
}
func patAuthorizationURIFromData(data map[string]any) string {
for _, key := range []string{"uri", "authUrl", "authorizationUrl"} {
value, _ := data[key].(string)
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
// WaitForPatAuthorization polls until the user completes authorization or timeout.
// It returns true if authorization was completed, false if timed out or cancelled.
func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Writer) bool {
bold := color.New(color.Bold).SprintFunc()
yellow := color.New(color.FgYellow).SprintFunc()
green := color.New(color.FgGreen).SprintFunc()
red := color.New(color.FgRed).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
timeout := PatAuthRetryTimeout
deadline := time.Now().Add(timeout)
pollTicker := time.NewTicker(PatAuthPollInterval)
@@ -249,21 +263,21 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
start := time.Now()
fmt.Fprintln(output)
fmt.Fprintf(output, "%s %s\n", yellow("⏳"), bold("等待用户授权..."))
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", dim("ℹ"))
fmt.Fprintf(output, " %s 超时时间: %s\n", dim("⏱"), timeout)
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
fmt.Fprintf(output, " %s 请在另一个终端完成 dws auth login 授权\n", tui.Dim("ℹ"))
fmt.Fprintf(output, " %s 超时时间: %s\n", tui.Dim("⏱"), timeout)
fmt.Fprintln(output)
pollCount := 0
for {
select {
case <-ctx.Done():
fmt.Fprintf(output, "%s 操作已取消\n", red("✗"))
fmt.Fprintf(output, "%s 操作已取消\n", tui.StateMark("error"))
return false
case <-time.After(time.Until(deadline)):
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", red("✗"), timeout)
fmt.Fprintf(output, " %s 请重新执行命令\n", dim("ℹ"))
fmt.Fprintf(output, "%s 等待授权超时 (%s)\n", tui.StateMark("error"), timeout)
fmt.Fprintf(output, " %s 请重新执行命令\n", tui.Dim("ℹ"))
return false
case <-pollTicker.C:
@@ -276,7 +290,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
if err == nil && tokenData != nil {
if tokenData.IsAccessTokenValid() || tokenData.IsRefreshTokenValid() {
fmt.Fprintf(output, "\r%s %s (%s 已用, %s 剩余) \n",
green("✓"), bold("授权成功!"), elapsed, remaining)
tui.StateMark("ok"), tui.Bold("授权成功!"), elapsed, remaining)
fmt.Fprintln(output)
return true
}
@@ -284,7 +298,7 @@ func WaitForPatAuthorization(ctx context.Context, configDir string, output io.Wr
// Show polling status
fmt.Fprintf(output, "\r%s [%d] 等待授权中... (%s 已用, %s 剩余) ",
dim("⟳"), pollCount, elapsed, remaining)
tui.Dim("⟳"), pollCount, elapsed, remaining)
}
}
}
@@ -324,8 +338,7 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
// Retry the invocation
fmt.Fprintln(output)
fmt.Fprintf(output, "%s %s\n", color.New(color.FgGreen).SprintFunc()("▶"),
color.New(color.Bold).SprintFunc()("授权完成,正在重试..."))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
fmt.Fprintln(output)
return runner.Run(ctx, invocation)
@@ -336,6 +349,9 @@ func retryWithPatAuthRetry(ctx context.Context, runner executor.Runner, invocati
const (
// patPollInterval is how often we poll the device flow status endpoint.
patPollInterval = 2 * time.Second
// patMaxPollInterval caps a server-provided poll interval so a malformed
// response cannot make the CLI look permanently stuck.
patMaxPollInterval = 30 * time.Second
// patPollTimeout is the maximum time to wait for user authorization via device flow.
patPollTimeout = 10 * time.Minute
)
@@ -346,6 +362,12 @@ type patRetryingKeyType struct{}
var patRetryingKey = patRetryingKeyType{}
type patRetryRunnerFunc func(context.Context, executor.Invocation) (executor.Result, error)
func (f patRetryRunnerFunc) Run(ctx context.Context, invocation executor.Invocation) (executor.Result, error) {
return f(ctx, invocation)
}
// IsPatRetrying returns true if the current context is already in a PAT retry.
func IsPatRetrying(ctx context.Context) bool {
v, _ := ctx.Value(patRetryingKey).(bool)
@@ -359,10 +381,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 before being
// printed, opened, or returned in structured output.
return openBrowserFunc(apperrors.PATAuthorizationURL(rawURI))
}
func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
@@ -378,6 +400,70 @@ func printPATPollDebugResponse(output io.Writer, statusCode int, body []byte) {
fmt.Fprintf(output, " %s\n", trimmed)
}
func runDirectPATAuthCheck(
ctx context.Context,
globalFlags *GlobalFlags,
patErr *apperrors.PATError,
retry func(context.Context) error,
output io.Writer,
) error {
if retry == nil {
return patErr
}
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, retry, output, true)
}
func runDirectPATAuthCheckWaitOnly(
ctx context.Context,
globalFlags *GlobalFlags,
patErr *apperrors.PATError,
output io.Writer,
) error {
ctx = context.WithValue(ctx, patSuppressBrowserOpenKey, true)
return runDirectPATAuthCheckWithMode(ctx, globalFlags, patErr, nil, output, false)
}
func runDirectPATAuthCheckWithMode(
ctx context.Context,
globalFlags *GlobalFlags,
patErr *apperrors.PATError,
retry func(context.Context) error,
output io.Writer,
retryAfterApproval bool,
) error {
if retryAfterApproval && retry == nil {
return patErr
}
runner := &runtimeRunner{
globalFlags: globalFlags,
fallback: patRetryRunnerFunc(func(retryCtx context.Context, invocation executor.Invocation) (executor.Result, error) {
if retry != nil {
if err := retry(retryCtx); err != nil {
return executor.Result{}, err
}
}
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"ok": true,
},
}, nil
}),
}
_, err := handlePatAuthCheck(ctx, runner, executor.Invocation{
Kind: "direct_pat_authorization",
Stage: "auth_login_recommend",
CanonicalProduct: defaultPATProductID,
Tool: "pat.batch_grant",
CanonicalPath: "pat.batch_grant",
Params: map[string]any{
"retryAfterApproval": retryAfterApproval,
},
}, patErr, defaultConfigDir(), output)
return err
}
// handlePatAuthCheck is called by runner.executeInvocation when a PAT
// authorization error is detected. It injects the server-assigned clientId
// as x-robot-uid header, prints authorization details, opens the browser,
@@ -395,16 +481,25 @@ func handlePatAuthCheck(
var patData struct {
Code string `json:"code"`
Data struct {
Desc string `json:"desc"`
FlowID string `json:"flowId"`
URI string `json:"uri"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
Desc string `json:"desc"`
FlowID string `json:"flowId"`
URI string `json:"uri"`
AuthURL string `json:"authUrl"`
AuthorizationURL string `json:"authorizationUrl"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
PollIntervalSecs int `json:"pollIntervalSeconds"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(patErr.RawJSON), &patData); err != nil {
return executor.Result{}, patErr
}
if patData.Data.URI == "" {
patData.Data.URI = patData.Data.AuthURL
}
if patData.Data.URI == "" {
patData.Data.URI = patData.Data.AuthorizationURL
}
slog.Debug("PAT auth check",
"clientId", patData.Data.ClientID,
@@ -412,7 +507,7 @@ func handlePatAuthCheck(
"hasSecret", patData.Data.ClientSecret != "",
)
hostOwnedPAT := authpkg.HostOwnsPATFlow()
openBrowser := currentPATOpenBrowser(configDir)
openBrowser := currentPATOpenBrowser(ctx, configDir)
slog.Debug("pat.host_owned_decision",
"site", "handlePatAuthCheck",
"hostOwned", hostOwnedPAT,
@@ -457,47 +552,45 @@ 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)}
}
bold := color.New(color.Bold).SprintFunc()
cyan := color.New(color.FgCyan).SprintFunc()
greenFn := color.New(color.FgGreen).SprintFunc()
yellowFn := color.New(color.FgYellow).SprintFunc()
redFn := color.New(color.FgRed).SprintFunc()
dim := color.New(color.Faint).SprintFunc()
fmt.Fprintln(output)
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("需要 PAT 授权"))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("warning"), tui.Bold("需要 PAT 授权"))
if patData.Data.Desc != "" {
fmt.Fprintf(output, " %s %s\n", dim("ℹ"), patData.Data.Desc)
fmt.Fprintf(output, " %s %s\n", tui.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", tui.Dim("🔗"), authURL)
fmt.Fprintln(output)
if openBrowser {
_ = openPATAuthorizationURI(patData.Data.URI)
_ = openPATAuthorizationURI(authURL)
}
}
// Poll the device flow status until user authorizes, rejects, or timeout.
fmt.Fprintf(output, "%s %s\n", yellowFn("⏳"), bold("等待用户授权..."))
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", dim("ℹ"), patPollTimeout)
fmt.Fprintf(output, "%s %s\n", tui.StateMark("pending"), tui.Bold("等待用户授权..."))
fmt.Fprintf(output, " %s 请在浏览器中完成授权,超时时间: %s\n", tui.Dim("ℹ"), patPollTimeout)
fmt.Fprintln(output)
pollCtx, cancel := context.WithTimeout(ctx, patPollTimeout)
defer cancel()
status, authCode, err := pollPatDeviceFlow(pollCtx, patData.Data.FlowID, configDir, output)
status, authCode, err := pollPatDeviceFlowWithInterval(
pollCtx, patData.Data.FlowID, configDir, output,
resolvePATPollInterval(patData.Data.PollIntervalSecs),
)
if err != nil {
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", redFn("✗"), err)
fmt.Fprintf(output, "%s 轮询授权状态失败: %v\n", tui.StateMark("error"), err)
return executor.Result{}, patErr
}
switch status {
case authpkg.StatusApproved:
fmt.Fprintf(output, "%s %s\n", greenFn("✓"), bold("授权成功!"))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权成功!"))
fmt.Fprintln(output)
if appCfg != nil {
@@ -513,11 +606,11 @@ func handlePatAuthCheck(
tokenData, exchErr := authpkg.ExchangeCodeForToken(ctx, configDir, authCode)
if exchErr != nil {
slog.Warn("PAT retry: exchangeCode failed, retrying with existing token", "error", exchErr)
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", yellowFn("⚠"), exchErr)
fmt.Fprintf(output, " %s 换取新 token 失败: %v (将使用现有凭证重试)\n", tui.StateMark("warning"), exchErr)
} else {
if err := authpkg.SaveTokenData(configDir, tokenData); err != nil {
slog.Warn("PAT retry: failed to save new token", "error", err)
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", yellowFn("⚠"), err)
fmt.Fprintf(output, " %s 保存新 token 失败: %v\n", tui.StateMark("warning"), err)
} else {
slog.Debug("PAT retry: token refreshed and saved")
}
@@ -527,13 +620,23 @@ func handlePatAuthCheck(
// Clear token cache so the new credentials take effect.
ResetRuntimeTokenCache()
if shouldSkipPATRetryAfterApproval(invocation) {
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"ok": true,
},
}, nil
}
// Workaround: brief delay to let server-side authorization state propagate
// before retrying. Without this the retry may use stale credentials.
slog.Debug("PAT retry: waiting for server-side state propagation", "delay", "1s")
time.Sleep(1 * time.Second)
// Retry the original invocation with pat-retrying flag to prevent recursion.
fmt.Fprintf(output, "%s %s\n", greenFn("▶"), bold("授权完成,正在重试..."))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("ok"), tui.Bold("授权完成,正在重试..."))
fmt.Fprintln(output)
slog.Debug("PAT retry: identity env check",
"DWS_CLIENT_ID", os.Getenv("DWS_CLIENT_ID"),
@@ -542,7 +645,7 @@ func handlePatAuthCheck(
return r.Run(retryCtx, invocation)
case authpkg.StatusRejected:
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("用户已拒绝授权"))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("用户已拒绝授权"))
return executor.Result{}, apperrors.NewAuth(
"用户已拒绝授权",
apperrors.WithReason("pat_auth_rejected"),
@@ -550,7 +653,7 @@ func handlePatAuthCheck(
)
case authpkg.StatusExpired:
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("授权超时"))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("授权超时"))
return executor.Result{}, apperrors.NewAuth(
"授权超时",
apperrors.WithReason("pat_auth_expired"),
@@ -558,7 +661,7 @@ func handlePatAuthCheck(
)
case authpkg.StatusCancelled:
fmt.Fprintf(output, "%s %s\n", redFn("✗"), bold("操作已取消"))
fmt.Fprintf(output, "%s %s\n", tui.StateMark("error"), tui.Bold("操作已取消"))
return executor.Result{}, apperrors.NewAuth(
"操作已取消",
apperrors.WithReason("pat_auth_cancelled"),
@@ -566,11 +669,23 @@ func handlePatAuthCheck(
)
default:
fmt.Fprintf(output, "%s 未知授权状态: %s\n", redFn("✗"), status)
fmt.Fprintf(output, "%s 未知授权状态: %s\n", tui.StateMark("error"), status)
return executor.Result{}, patErr
}
}
func shouldSkipPATRetryAfterApproval(invocation executor.Invocation) bool {
if invocation.Params == nil {
return false
}
value, ok := invocation.Params["retryAfterApproval"]
if !ok {
return false
}
retry, ok := value.(bool)
return ok && !retry
}
func enrichPATErrorForHostControl(raw string) string {
if strings.TrimSpace(raw) == "" {
return raw
@@ -590,7 +705,7 @@ func enrichPATErrorForHostControl(raw string) string {
apperrors.ApplyHostMutations(payload)
// stderr JSON MUST be single-line.
encoded, err := json.Marshal(payload)
encoded, err := marshalSingleLineJSONNoHTMLEscape(payload)
if err != nil {
return raw
}
@@ -624,17 +739,38 @@ func buildPATScopeJSON(scopeErr *PatScopeError, includeHostControl bool) string
"data": data,
}
// stderr JSON MUST be single-line.
b, err := json.Marshal(payload)
b, err := jsonutil.Marshal(payload)
if err != nil {
return `{"success":false,"code":"PAT_SCOPE_AUTH_REQUIRED"}`
}
return string(b)
}
func marshalSingleLineJSONNoHTMLEscape(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
out := buf.Bytes()
if len(out) > 0 && out[len(out)-1] == '\n' {
out = out[:len(out)-1]
}
return out, nil
}
// pollPatDeviceFlow polls the PAT device flow status endpoint until a terminal
// state (APPROVED/REJECTED/EXPIRED) is reached or the context is cancelled.
// Returns the final status string and the authCode (non-empty only on APPROVED).
func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, output io.Writer) (string, string, error) {
return pollPatDeviceFlowWithInterval(ctx, flowID, configDir, output, patPollInterval)
}
func pollPatDeviceFlowWithInterval(ctx context.Context, flowID string, configDir string, output io.Writer, interval time.Duration) (string, string, error) {
if interval <= 0 {
interval = patPollInterval
}
pollURL := fmt.Sprintf("%s%s?flowId=%s",
authpkg.GetMCPBaseURL(), authpkg.DevicePollPath, url.QueryEscape(flowID))
@@ -651,10 +787,9 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
},
}
ticker := time.NewTicker(patPollInterval)
ticker := time.NewTicker(interval)
defer ticker.Stop()
dim := color.New(color.Faint).SprintFunc()
pollCount := 0
for {
@@ -666,7 +801,7 @@ func pollPatDeviceFlow(ctx context.Context, flowID string, configDir string, out
return authpkg.StatusExpired, "", nil
case <-ticker.C:
pollCount++
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", dim("⟳"), pollCount)
fmt.Fprintf(output, "\r%s [%d] 等待授权中... ", tui.Dim("⟳"), pollCount)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pollURL, nil)
if err != nil {
@@ -718,18 +853,38 @@ 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 resolvePATPollInterval(seconds int) time.Duration {
if seconds <= 0 {
return patPollInterval
}
interval := time.Duration(seconds) * time.Second
if interval < time.Second {
return time.Second
}
if interval > patMaxPollInterval {
return patMaxPollInterval
}
return interval
}
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()
}
+382 -9
View File
@@ -129,6 +129,10 @@ func TestPrintPatAuthError_HumanReadable(t *testing.T) {
func TestPrintPatAuthJSON_MachineReadable(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
// PAT browser policy is user-configurable. Isolate the config directory so
// this serializer test exercises the built-in CLI-owned default instead of
// inheriting the developer's ~/.dws/pat_policy.json.
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
var buf strings.Builder
scopeErr := &PatScopeError{
Identity: "user",
@@ -590,6 +594,62 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
return string(data)
}
func makePATErrorJSONWithAuthorizationURL(flowID, clientID, authURL string) string {
type patData struct {
Desc string `json:"desc"`
FlowID string `json:"flowId"`
AuthorizationURL string `json:"authorizationUrl"`
ClientID string `json:"clientId"`
}
payload := struct {
Code string `json:"code"`
Data patData `json:"data"`
}{
Code: "AGENT_CODE_NOT_EXISTS",
Data: patData{
Desc: "test auth",
FlowID: flowID,
AuthorizationURL: authURL,
ClientID: clientID,
},
}
data, _ := json.Marshal(payload)
return string(data)
}
func patTestAuthorizationURL(server *httptest.Server) string {
return server.URL + "/pat"
}
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
out := enrichPATErrorWithOpenBrowser(raw, true)
if strings.Contains(out, `\u0026`) {
t.Fatalf("enriched PAT JSON should keep URL ampersands readable for mobile copy/linkify, got: %s", out)
}
if !strings.Contains(out, "&userCode=QZYH-D64W") {
t.Fatalf("enriched PAT JSON missing readable authorization URL separator, got: %s", out)
}
var payload map[string]any
if err := json.Unmarshal([]byte(out), &payload); err != nil {
t.Fatalf("json.Unmarshal(enriched PAT payload) error = %v\nraw=%s", err, out)
}
data, _ := payload["data"].(map[string]any)
if got, _ := data["uri"].(string); got != rawURI {
t.Fatalf("data.uri = %q, want %q", got, rawURI)
}
if _, ok := data["authUrl"]; ok {
t.Fatalf("data.authUrl should be omitted from enriched PAT payload")
}
if _, ok := data["authorizationUrl"]; ok {
t.Fatalf("data.authorizationUrl should be omitted from enriched PAT payload")
}
}
func TestHandlePatAuthCheck_Approved(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "APPROVED", "test-auth-code")
@@ -624,7 +684,7 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
if !retryHasKey {
t.Fatal("expected retry context to have patRetryingKey")
}
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err != nil {
if _, err := os.Stat(authpkg.GetAppConfigPath(configDir)); err != nil {
t.Fatalf("expected approved PAT flow to persist app.json, stat error = %v", err)
}
// Verify SetClientIDFromMCP was called with the PAT response clientId.
@@ -633,6 +693,195 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
}
}
func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "APPROVED", "")
defer server.Close()
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var retried atomic.Bool
var retryHadKey atomic.Bool
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error {
retried.Store(true)
retryHadKey.Store(IsPatRetrying(ctx))
return nil
}, &bytes.Buffer{})
if err != nil {
t.Fatalf("runDirectPATAuthCheck error = %v", err)
}
if !retried.Load() {
t.Fatal("expected direct PAT auth retry callback to run")
}
if !retryHadKey.Load() {
t.Fatal("expected direct PAT auth retry context to be marked as PAT retrying")
}
}
func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, _ := setupHandlePATServer(t, "APPROVED", "")
defer server.Close()
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var out bytes.Buffer
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
if err != nil {
t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err)
}
if strings.Contains(out.String(), "授权完成,正在重试") {
t.Fatalf("wait-only auth must not print retry prompt, output:\n%s", out.String())
}
if !strings.Contains(out.String(), "授权成功") {
t.Fatalf("wait-only auth should still report success, output:\n%s", out.String())
}
}
func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "APPROVED", "")
defer server.Close()
if _, err := pat.SetBrowserPolicy(configDir, "", true); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
var opened bool
origOpenBrowser := openBrowserFunc
openBrowserFunc = func(rawURL string) error {
opened = true
return nil
}
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
var out bytes.Buffer
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
if err != nil {
t.Fatalf("runDirectPATAuthCheckWaitOnly error = %v", err)
}
if opened {
t.Fatal("wait-only auth must not open a second browser tab")
}
if !strings.Contains(out.String(), "授权链接:") {
t.Fatalf("wait-only auth should still print the authorization URL, output:\n%s", out.String())
}
}
func TestResolvePATPollInterval(t *testing.T) {
tests := []struct {
name string
seconds int
want time.Duration
}{
{name: "default", seconds: 0, want: patPollInterval},
{name: "server value", seconds: 3, want: 3 * time.Second},
{name: "cap excessive value", seconds: 90, want: patMaxPollInterval},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := resolvePATPollInterval(tt.seconds); got != tt.want {
t.Fatalf("resolvePATPollInterval(%d) = %s, want %s", tt.seconds, got, tt.want)
}
})
}
}
func TestRunDirectPATAuthCheck_JSONModeReturnsStructuredPending(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
rawURI := "https://example.com/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH"
raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","uri":"` + rawURI + `","authUrl":"` + rawURI + `","clientId":"test-client-id"}}`
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"},
&apperrors.PATError{RawJSON: raw},
func(ctx context.Context) error {
t.Fatal("retry callback should not run in structured PAT output mode")
return nil
},
&bytes.Buffer{},
)
if err == nil {
t.Fatal("expected structured PATError")
}
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(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
}
if got, _ := payload["code"].(string); got != "PAT_BATCH_AUTH_PENDING" {
t.Fatalf("code = %q, want PAT_BATCH_AUTH_PENDING", got)
}
data, _ := payload["data"].(map[string]any)
if got, _ := data["uri"].(string); got != rawURI {
t.Fatalf("data.uri = %q, want %q", got, rawURI)
}
if _, ok := data["authUrl"]; ok {
t.Fatalf("data.authUrl should be omitted from structured PAT output")
}
if _, ok := data["authorizationUrl"]; ok {
t.Fatalf("data.authorizationUrl should be omitted from structured PAT output")
}
if got, ok := data["openBrowser"].(bool); !ok || got {
t.Fatalf("data.openBrowser = %#v, want false", data["openBrowser"])
}
}
func TestRunDirectPATAuthCheck_JSONModeBackfillsSingleURIFromAuthURL(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
}
rawURL := "https://open-dev.dingtalk.com/fe/old#%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH"
wantURL := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-json%26userCode%3DABCD-EFGH#/personalAuthorization?flowId=flow-json&userCode=ABCD-EFGH"
raw := `{"success":false,"code":"PAT_BATCH_AUTH_PENDING","data":{"flowId":"flow-json","authUrl":"` + rawURL + `","clientId":"test-client-id"}}`
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{Format: "json"},
&apperrors.PATError{RawJSON: raw},
func(ctx context.Context) error {
t.Fatal("retry callback should not run in structured PAT output mode")
return nil
},
&bytes.Buffer{},
)
if err == nil {
t.Fatal("expected structured PATError")
}
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(PAT payload) error = %v\nraw=%s", err, patOut.RawJSON)
}
if strings.Contains(patOut.RawJSON, `\u0026`) {
t.Fatalf("PAT output escaped URL separators: %s", patOut.RawJSON)
}
data, _ := payload["data"].(map[string]any)
if got, _ := data["uri"].(string); got != wantURL {
t.Fatalf("data.uri = %q, want %q", got, wantURL)
}
if _, ok := data["authUrl"]; ok {
t.Fatalf("data.authUrl should be omitted after backfilling data.uri")
}
if _, ok := data["authorizationUrl"]; ok {
t.Fatalf("data.authorizationUrl should be omitted after backfilling data.uri")
}
}
func TestHandlePatAuthCheck_Rejected(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "")
server, configDir := setupHandlePATServer(t, "REJECTED", "")
@@ -700,7 +949,7 @@ func TestHandlePatAuthCheck_HostControlledFlowIDPassthrough(t *testing.T) {
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in host mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
}
@@ -759,7 +1008,7 @@ func TestHandlePatAuthCheck_HostControlledEmptyFlowID_StillReturnsContract(t *te
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in host mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("host-owned PAT must not persist shared app.json, stat error = %v", err)
}
patOut, ok := err.(*apperrors.PATError)
@@ -855,7 +1104,7 @@ func TestHandlePatAuthCheck_JSONModeReturnsStructuredPATErrorWithoutRetry(t *tes
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
}
@@ -903,7 +1152,8 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
fallback: mock,
globalFlags: &GlobalFlags{Format: "json"},
}
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"https://example.com/pat","clientId":"test-client-id"}}`
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Df72437f040f04a8295988ff71e690b35%26userCode%3D98JV-JSBL#/personalAuthorization?flowId=f72437f040f04a8295988ff71e690b35&userCode=98JV-JSBL"
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-json","uri":"` + rawURI + `","clientId":"test-client-id"}}`
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
@@ -917,11 +1167,38 @@ func TestHandlePatAuthCheck_JSONModeCanOpenBrowserWithoutTextOutput(t *testing.T
if got := strings.TrimSpace(buf.String()); got != "" {
t.Fatalf("expected no human-readable output in json PAT mode, got %q", got)
}
if _, err := os.Stat(filepath.Join(tmpDir, "app.json")); !os.IsNotExist(err) {
if _, err := os.Stat(authpkg.GetAppConfigPath(tmpDir)); !os.IsNotExist(err) {
t.Fatalf("json PAT mode must not persist shared app.json, stat error = %v", err)
}
if opened != "https://example.com/pat" {
t.Fatalf("opened url = %q, want https://example.com/pat", opened)
if opened != rawURI {
t.Fatalf("opened url = %q, want verbatim %q", opened, rawURI)
}
patOut, ok := err.(*apperrors.PATError)
if !ok {
t.Fatalf("expected *PATError, got %T: %v", err, err)
}
if strings.Contains(patOut.RawJSON, `\u0026`) {
t.Fatalf("PATError RawJSON escaped ampersands in authorization URL: %s", patOut.RawJSON)
}
if !strings.Contains(patOut.RawJSON, "&userCode=98JV-JSBL") {
t.Fatalf("PATError RawJSON missing literal ampersand route separator: %s", patOut.RawJSON)
}
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 _, ok := data["authUrl"]; ok {
t.Fatalf("data.authUrl should be omitted from json PAT output")
}
if _, ok := data["authorizationUrl"]; ok {
t.Fatalf("data.authorizationUrl should be omitted from json PAT output")
}
if got, ok := data["openBrowser"].(bool); !ok || !got {
t.Fatalf("data.openBrowser = %#v, want true", data["openBrowser"])
}
}
@@ -953,7 +1230,8 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
fallback: mock,
globalFlags: &GlobalFlags{Format: "table"},
}
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","uri":"https://example.com/pat","clientId":"test-client-id"}}`
authURL := patTestAuthorizationURL(server)
raw := makePATErrorJSONWithAuthorizationURL("flow-approved", "test-client-id", authURL)
var buf bytes.Buffer
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
@@ -973,6 +1251,12 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
if !strings.Contains(buf.String(), "需要 PAT 授权") {
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
}
if !strings.Contains(buf.String(), "授权链接: "+authURL) {
t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String())
}
if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") {
t.Fatalf("human-readable PAT output should not emit a second machine-readable URL line, got %q", buf.String())
}
}
func TestRetryWithPatAuthRetry_JSONModeReturnsStructuredPATError(t *testing.T) {
@@ -1063,6 +1347,21 @@ func TestEnrichPATErrorForHostControl_SingleLineOutput(t *testing.T) {
}
}
func TestEnrichPATErrorForHostControlKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
t.Setenv(authpkg.AgentCodeEnv, "agt-sales")
t.Setenv("DINGTALK_AGENT", "sales-copilot")
raw := `{"success":false,"code":"PAT_HIGH_RISK_NO_PERMISSION","data":{"flowId":"flow-host","desc":"授权","uri":"https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-host%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-host&userCode=QZYH-D64W"}}`
out := enrichPATErrorForHostControl(raw)
if strings.Contains(out, `\u0026`) {
t.Fatalf("host PAT JSON should keep URL ampersands readable for mobile copy/linkify, got: %s", out)
}
if !strings.Contains(out, "&userCode=QZYH-D64W") {
t.Fatalf("host PAT JSON missing readable authorization URL separator, got: %s", out)
}
}
// TestBuildPATScopeHostJSON_SingleLineOutput mirrors the above regression
// for the scope-error branch (PAT_SCOPE_AUTH_REQUIRED emission).
func TestBuildPATScopeHostJSON_SingleLineOutput(t *testing.T) {
@@ -1189,4 +1488,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=") {
t.Fatalf("human-readable PAT output should not emit 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=") {
t.Fatalf("human-readable PAT output should not emit 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)
}
}
@@ -1,198 +0,0 @@
// 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 (
"fmt"
"sync"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// TestSharedCacheStoreConcurrentSaveTools verifies that a single *cache.Store
// instance is safe for goroutines saving tool snapshots concurrently, as long
// as each goroutine targets a distinct (partition, serverKey). This mirrors
// the real plugin discovery path where each goroutine owns one plugin/server.
//
// Each call serializes to its own "<key>.json.tmp" file followed by a
// rename(2) to the final path, so concurrent writers targeting distinct keys
// never collide. The invariant asserted here: after N parallel writes, the
// Store returns each written snapshot intact under LoadTools.
func TestSharedCacheStoreConcurrentSaveTools(t *testing.T) {
const (
partition = "default/default"
writers = 16
)
store := cache.NewStore(t.TempDir())
var wg sync.WaitGroup
for i := 0; i < writers; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
key := fmt.Sprintf("plugin:concurrent:%d", idx)
if err := store.SaveTools(partition, key, cache.ToolsSnapshot{
ServerKey: key,
}); err != nil {
t.Errorf("SaveTools(%s): %v", key, err)
}
}(i)
}
wg.Wait()
for i := 0; i < writers; i++ {
key := fmt.Sprintf("plugin:concurrent:%d", i)
snapshot, _, err := store.LoadTools(partition, key)
if err != nil {
t.Fatalf("LoadTools(%s): %v", key, err)
}
if snapshot.ServerKey != key {
t.Errorf("LoadTools(%s) returned ServerKey %q", key, snapshot.ServerKey)
}
}
}
// TestAppendDynamicServerConcurrent exercises the dynamicMu mutex on the
// write path by spraying distinct server descriptors in parallel. Afterwards
// every injected product ID must be resolvable — a missing entry would
// indicate a lost write through an un-synchronized map update.
func TestAppendDynamicServerConcurrent(t *testing.T) {
dynamicMu.Lock()
prev := struct {
endpoints map[string]string
products map[string]bool
aliases map[string]string
toolEndpoints map[string]string
}{dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints}
dynamicEndpoints = nil
dynamicProducts = nil
dynamicAliases = nil
dynamicToolEndpoints = nil
dynamicMu.Unlock()
t.Cleanup(func() {
dynamicMu.Lock()
dynamicEndpoints = prev.endpoints
dynamicProducts = prev.products
dynamicAliases = prev.aliases
dynamicToolEndpoints = prev.toolEndpoints
dynamicMu.Unlock()
})
const n = 32
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
id := fmt.Sprintf("plugin-id-%d", idx)
endpoint := fmt.Sprintf("https://example.test/%d", idx)
AppendDynamicServer(market.ServerDescriptor{
Endpoint: endpoint,
CLI: market.CLIOverlay{
ID: id,
Command: id,
},
})
}(i)
}
wg.Wait()
for i := 0; i < n; i++ {
id := fmt.Sprintf("plugin-id-%d", i)
if endpoint, ok := directRuntimeEndpoint(id, ""); !ok || endpoint == "" {
t.Errorf("directRuntimeEndpoint(%q) = (%q, %v), want non-empty", id, endpoint, ok)
}
}
}
// TestRegisterStdioClientConcurrent verifies the stdioMu-protected registry
// survives concurrent writers — every registered client must be looked up
// afterwards. Uses nil client pointers since LookupStdioClient only compares
// keys, not values.
func TestRegisterStdioClientConcurrent(t *testing.T) {
stdioMu.Lock()
prev := stdioClients
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
t.Cleanup(func() {
stdioMu.Lock()
stdioClients = prev
stdioMu.Unlock()
})
const n = 32
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
RegisterStdioClient(fmt.Sprintf("plugin/%d", idx), nil)
}(i)
}
wg.Wait()
for i := 0; i < n; i++ {
key := fmt.Sprintf("plugin/%d", i)
if _, ok := LookupStdioClient(key); !ok {
t.Errorf("LookupStdioClient(%q) missing after concurrent registration", key)
}
}
}
// TestResolvePluginColdTimeouts covers the three code paths of the env
// parser: unset (defaults), valid duration (applied to all three slots),
// and invalid duration (logged and ignored, defaults returned).
func TestResolvePluginColdTimeouts(t *testing.T) {
t.Run("defaults when env unset", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "")
got := resolvePluginColdTimeouts()
if got.httpNoAuth != 1*time.Second {
t.Errorf("httpNoAuth = %v, want 1s", got.httpNoAuth)
}
if got.httpAuth != 1500*time.Millisecond {
t.Errorf("httpAuth = %v, want 1.5s", got.httpAuth)
}
if got.stdio != 2*time.Second {
t.Errorf("stdio = %v, want 2s", got.stdio)
}
})
t.Run("env override applies to all slots", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "3500ms")
got := resolvePluginColdTimeouts()
want := 3500 * time.Millisecond
if got.httpNoAuth != want || got.httpAuth != want || got.stdio != want {
t.Errorf("override not propagated: %+v", got)
}
})
t.Run("invalid env falls back to defaults", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "not-a-duration")
got := resolvePluginColdTimeouts()
if got.httpNoAuth != 1*time.Second || got.stdio != 2*time.Second {
t.Errorf("invalid env should not override defaults: %+v", got)
}
})
t.Run("non-positive env falls back to defaults", func(t *testing.T) {
t.Setenv(cli.PluginColdTimeoutEnv, "0")
got := resolvePluginColdTimeouts()
if got.httpNoAuth != 1*time.Second {
t.Errorf("zero duration should not override defaults, got %v", got.httpNoAuth)
}
})
}
+98
View File
@@ -0,0 +1,98 @@
// 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.
package app
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
)
func isolatePluginRuntime(t *testing.T) {
t.Helper()
dynamicMu.Lock()
previousEndpoints := dynamicEndpoints
previousProducts := dynamicProducts
previousAliases := dynamicAliases
previousToolEndpoints := dynamicToolEndpoints
dynamicEndpoints = nil
dynamicProducts = nil
dynamicAliases = nil
dynamicToolEndpoints = nil
dynamicMu.Unlock()
stdioMu.Lock()
previousStdio := stdioClients
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
t.Cleanup(func() {
StopAllStdioClients()
dynamicMu.Lock()
dynamicEndpoints = previousEndpoints
dynamicProducts = previousProducts
dynamicAliases = previousAliases
dynamicToolEndpoints = previousToolEndpoints
dynamicMu.Unlock()
stdioMu.Lock()
stdioClients = previousStdio
stdioMu.Unlock()
})
}
func TestRegisterPluginHTTPServerDoesNotProbeEndpoint(t *testing.T) {
isolatePluginRuntime(t)
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
calls.Add(1)
}))
defer server.Close()
registerPluginHTTPServer(mcptypes.ServerDescriptor{
Key: "offline-http",
Endpoint: server.URL,
CLI: mcptypes.CLIOverlay{
ID: "offline-http",
Command: "offline-http",
},
})
if got := calls.Load(); got != 0 {
t.Fatalf("plugin endpoint calls during registration = %d, want 0", got)
}
if endpoint, ok := directRuntimeEndpoint("offline-http", ""); !ok || endpoint != server.URL {
t.Fatalf("registered endpoint = (%q, %v), want (%q, true)", endpoint, ok, server.URL)
}
}
func TestRegisterStdioServerFromManifestDoesNotStartProcess(t *testing.T) {
isolatePluginRuntime(t)
marker := t.TempDir() + "/started"
client := transport.NewStdioClient("/bin/sh", []string{
"-c", fmt.Sprintf("printf started > %q", marker),
}, nil)
p := &plugin.Plugin{
Manifest: plugin.Manifest{Name: "lazy-stdio", Description: "lazy stdio test"},
Root: t.TempDir(),
}
descriptor := registerStdioServerFromManifest(p, plugin.StdioServerClient{Key: "local", Client: client})
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("stdio process started during registration: stat error = %v", err)
}
if descriptor.Endpoint != StdioEndpoint("lazy-stdio", "local") {
t.Fatalf("descriptor endpoint = %q", descriptor.Endpoint)
}
if _, ok := LookupStdioClient("lazy-stdio/local"); !ok {
t.Fatal("stdio client was not registered for lazy execution")
}
}
+11 -121
View File
@@ -19,14 +19,8 @@ import (
"os"
"path/filepath"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/compat"
"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/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
)
// resolveStdioOverlay resolves the CLIOverlay for a stdio plugin server
@@ -38,9 +32,9 @@ import (
//
// When no CLI metadata is present, a minimal overlay keyed by the server
// name is returned so callers can still build an identity descriptor.
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.CLIOverlay {
func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.CLIOverlay {
serverID := sc.Key
overlay := market.CLIOverlay{
overlay := mcptypes.CLIOverlay{
ID: serverID,
Command: serverID,
}
@@ -77,55 +71,12 @@ func resolveStdioOverlay(p *plugin.Plugin, sc plugin.StdioServerClient) market.C
return overlay
}
// toolsToDetails converts discovered ToolDescriptors to the DetailTool map
// shape expected by compat.BuildDynamicCommands (keyed by overlay ID).
// Returns nil if tools is empty.
func toolsToDetails(tools []transport.ToolDescriptor, overlayID string) map[string][]market.DetailTool {
if len(tools) == 0 {
return nil
}
detailTools := make([]market.DetailTool, 0, len(tools))
for _, tool := range tools {
schemaJSON := ""
if tool.InputSchema != nil {
if data, marshalErr := json.Marshal(tool.InputSchema); marshalErr == nil {
schemaJSON = string(data)
}
}
detailTools = append(detailTools, market.DetailTool{
ToolName: tool.Name,
ToolTitle: tool.Title,
ToolDesc: tool.Description,
IsSensitive: tool.Sensitive,
ToolRequest: schemaJSON,
})
}
return map[string][]market.DetailTool{overlayID: detailTools}
}
// registerStdioServerFromOverlay builds cobra commands for a stdio plugin
// server using only its manifest + overlay.json — no subprocess required.
//
// Returns (cmds, descriptor, true) when the overlay carries toolOverrides,
// otherwise (nil, zero, false) so the caller can fall back to discovery-first
// registration (legacy path).
//
// When a warm tools cache exists for this server, its DetailTools are passed
// to BuildDynamicCommands so flag types are enriched from the last successful
// discovery. Fresh installs (or evicted caches) get overlay-declared flags
// only; the next startup after a successful refresh picks up the full schema.
func registerStdioServerFromOverlay(
p *plugin.Plugin,
sc plugin.StdioServerClient,
runner executor.Runner,
store *cache.Store,
) ([]*cobra.Command, market.ServerDescriptor, bool) {
// registerStdioServerFromManifest registers an endpoint descriptor and an
// unstarted client from versioned plugin metadata. Tool discovery is not part
// of command-tree construction; execution starts and initializes the client.
func registerStdioServerFromManifest(p *plugin.Plugin, sc plugin.StdioServerClient) mcptypes.ServerDescriptor {
overlay := resolveStdioOverlay(p, sc)
if len(overlay.ToolOverrides) == 0 {
return nil, market.ServerDescriptor{}, false
}
descriptor := market.ServerDescriptor{
descriptor := mcptypes.ServerDescriptor{
Key: sc.Key,
DisplayName: p.Manifest.Name + "/" + sc.Key,
Description: p.Manifest.Description,
@@ -138,69 +89,8 @@ func registerStdioServerFromOverlay(
AppendDynamicServer(descriptor)
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
// Warm-cache enrichment: if a prior successful discovery wrote a
// non-empty tool list, use its schema to enrich flag types.
var detailsByID map[string][]market.DetailTool
if store != nil {
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if snapshot, _, err := store.LoadTools(config.DefaultPartition, cacheKey); err == nil && len(snapshot.Tools) > 0 {
detailsByID = toolsToDetails(snapshot.Tools, overlay.ID)
}
}
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
slog.Debug("plugin: stdio server registered from overlay",
slog.Debug("plugin: stdio server registered from manifest",
"plugin", p.Manifest.Name, "server", sc.Key,
"toolOverrides", len(overlay.ToolOverrides),
"commands", len(cmds),
"enriched", detailsByID != nil)
return cmds, descriptor, true
}
// refreshStdioToolsCache performs Initialize + ListTools on a stdio plugin
// subprocess and persists the result so the next startup can enrich
// overlay-registered commands with typed flags. It never constructs cobra
// commands; command registration has already happened synchronously from
// the overlay before this function runs.
//
// On failure (subprocess not ready, RPC timeout, empty tool list) it skips
// SaveTools entirely so a transient error cannot poison the warm cache
// with a null-tools snapshot.
func refreshStdioToolsCache(
p *plugin.Plugin,
sc plugin.StdioServerClient,
store *cache.Store,
timeouts pluginColdTimeouts,
) {
if store == nil {
return
}
tools := discoverStdioTools(p, sc, timeouts)
if len(tools) == 0 {
slog.Debug("plugin: stdio cache refresh skipped (no tools)",
"plugin", p.Manifest.Name, "server", sc.Key)
return
}
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
ServerKey: cacheKey,
Tools: tools,
}); err != nil {
slog.Warn("plugin: failed to persist stdio tools cache",
"plugin", p.Manifest.Name, "server", sc.Key, "error", err)
return
}
slog.Debug("plugin: stdio tools cache refreshed",
"plugin", p.Manifest.Name, "server", sc.Key, "tools", len(tools))
}
// hasOverlayToolOverrides reports whether a stdio plugin server carries
// enough CLI metadata to be registered via the overlay-first path. Used by
// loadPlugins to split entries into overlay-first vs. legacy discovery-first
// buckets without doing the overlay parse twice.
func hasOverlayToolOverrides(p *plugin.Plugin, sc plugin.StdioServerClient) bool {
return len(resolveStdioOverlay(p, sc).ToolOverrides) > 0
"toolOverrides", len(overlay.ToolOverrides))
return descriptor
}
-398
View File
@@ -1,398 +0,0 @@
// 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 (
"encoding/json"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"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/plugin"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/spf13/cobra"
)
// withCleanStdioRegistry snapshots and restores the package-level stdio
// client registry so tests that call RegisterStdioClient don't leak state
// across cases.
func withCleanStdioRegistry(t *testing.T) {
t.Helper()
stdioMu.Lock()
prev := stdioClients
stdioClients = make(map[string]*transport.StdioClient)
stdioMu.Unlock()
t.Cleanup(func() {
stdioMu.Lock()
stdioClients = prev
stdioMu.Unlock()
})
}
// newOverlayFixture constructs a plugin + stdio entry carrying an inline
// CLIOverlay with the given tool-override map. The stdio client is created
// but never started, since the overlay-first path does not require the
// subprocess to be running for command registration.
func newOverlayFixture(t *testing.T, pluginName, serverKey string, overlay market.CLIOverlay) (*plugin.Plugin, plugin.StdioServerClient) {
t.Helper()
raw, err := json.Marshal(overlay)
if err != nil {
t.Fatalf("marshal overlay: %v", err)
}
p := &plugin.Plugin{
Manifest: plugin.Manifest{
Name: pluginName,
Version: "1.0.0",
Description: pluginName + " plugin",
MCPServers: map[string]*plugin.MCPServer{
serverKey: {
Type: "stdio",
Command: "/usr/bin/true", // never executed by overlay-first path
CLI: raw,
},
},
},
Root: t.TempDir(),
}
sc := plugin.StdioServerClient{
Key: serverKey,
Client: transport.NewStdioClient("/usr/bin/true", nil, nil),
}
return p, sc
}
// TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands verifies
// the core promise of the overlay-first path: when overlay.json ships
// ToolOverrides, commands appear immediately — no subprocess probe.
func TestRegisterStdioServerFromOverlay_NoDiscoveryStillBuildsCommands(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
overlay := market.CLIOverlay{
ID: "conference-local",
Command: "conference-local",
Groups: map[string]market.CLIGroupDef{
"meeting": {Description: "会议控制"},
"member": {Description: "成员管理"},
},
ToolOverrides: map[string]market.CLIToolOverride{
"create_meeting": {CLIName: "create", Group: "meeting", Description: "Create a meeting"},
"end_meeting": {CLIName: "end", Group: "meeting", Description: "End a meeting"},
"mute_member": {CLIName: "mute", Group: "member", Description: "Mute a member"},
},
}
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
store := cache.NewStore(t.TempDir())
cmds, desc, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok {
t.Fatal("registerStdioServerFromOverlay returned ok=false, want true")
}
if len(cmds) == 0 {
t.Fatal("registerStdioServerFromOverlay returned 0 commands, want >=1")
}
var root *struct{ name, path string }
_ = root
found := false
for _, c := range cmds {
if c.Name() == "conference-local" {
found = true
// Groups must be attached as sub-commands.
groups := map[string]bool{}
for _, sub := range c.Commands() {
groups[sub.Name()] = true
}
if !groups["meeting"] {
t.Errorf("missing 'meeting' group sub-command, children = %v", groups)
}
if !groups["member"] {
t.Errorf("missing 'member' group sub-command, children = %v", groups)
}
}
}
if !found {
names := []string{}
for _, c := range cmds {
names = append(names, c.Name())
}
t.Fatalf("missing top-level 'conference-local' command, got %v", names)
}
// AppendDynamicServer registration: product ID should land in
// DirectRuntimeProductIDs so hideNonDirectRuntimeCommands keeps it
// visible even under a restrictive VisibleProducts hook.
if !DirectRuntimeProductIDs()["conference-local"] {
t.Error("DirectRuntimeProductIDs missing 'conference-local'")
}
// RegisterStdioClient side-effect: the runtime must be able to look up
// the StdioClient when the endpoint is invoked later.
if _, ok := LookupStdioClient("conference-local/conference-local"); !ok {
t.Error("LookupStdioClient missing conference-local/conference-local")
}
if desc.Endpoint != StdioEndpoint("conference-local", "conference-local") {
t.Errorf("descriptor.Endpoint = %q, want %q", desc.Endpoint, StdioEndpoint("conference-local", "conference-local"))
}
}
// TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags pre-populates the
// tools cache with a schema-bearing DetailTool and asserts the resulting
// leaf command picks up the typed flag derived from InputSchema.
func TestRegisterStdioServerFromOverlay_WarmCacheEnrichesFlags(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
overlay := market.CLIOverlay{
ID: "cache-plugin",
Command: "cache-plugin",
ToolOverrides: map[string]market.CLIToolOverride{
"echo": {CLIName: "echo", Description: "Echo input"},
},
}
p, sc := newOverlayFixture(t, "cache-plugin", "cache-plugin", overlay)
store := cache.NewStore(t.TempDir())
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
if err := store.SaveTools(config.DefaultPartition, cacheKey, cache.ToolsSnapshot{
SavedAt: time.Now().UTC(),
ServerKey: cacheKey,
Tools: []transport.ToolDescriptor{
{
Name: "echo",
Description: "Echo the input",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"message": map[string]any{"type": "string"},
},
"required": []any{"message"},
},
},
},
}); err != nil {
t.Fatalf("SaveTools: %v", err)
}
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok || len(cmds) == 0 {
t.Fatalf("overlay registration failed: ok=%v cmds=%d", ok, len(cmds))
}
var echoLeaf *leafMatch
for _, top := range cmds {
if top.Name() != "cache-plugin" {
continue
}
for _, sub := range top.Commands() {
if sub.Name() == "echo" {
echoLeaf = &leafMatch{name: sub.Name(), hasFlag: sub.Flags().Lookup("message") != nil}
}
}
}
if echoLeaf == nil {
t.Fatal("missing 'echo' leaf command under 'cache-plugin'")
}
if !echoLeaf.hasFlag {
t.Error("warm-cache enrichment did not wire --message flag from InputSchema")
}
}
type leafMatch struct {
name string
hasFlag bool
}
// TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse
// asserts the fallback contract: when overlay.json is missing toolOverrides,
// the overlay-first path declines so the caller can route the entry through
// the legacy discovery-first registerStdioServer.
func TestRegisterStdioServerFromOverlay_OverlayWithoutOverridesReturnsFalse(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
// Overlay with no ToolOverrides (simulates a plugin that relies entirely
// on runtime discovery for its tool list).
overlay := market.CLIOverlay{
ID: "legacy-plugin",
Command: "legacy-plugin",
}
p, sc := newOverlayFixture(t, "legacy-plugin", "legacy-plugin", overlay)
store := cache.NewStore(t.TempDir())
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if ok {
t.Errorf("registerStdioServerFromOverlay ok=true for empty toolOverrides; want false")
}
if cmds != nil {
t.Errorf("cmds = %v, want nil", cmds)
}
if DirectRuntimeProductIDs()["legacy-plugin"] {
t.Error("legacy-plugin must NOT be appended to dynamic registry in fallback case")
}
if _, found := LookupStdioClient("legacy-plugin/legacy-plugin"); found {
t.Error("stdio client must NOT be registered in fallback case")
}
}
// TestRefreshStdioToolsCache_FailurePreservesCache guards against the
// "negative cache poisoning" bug: if discovery fails (subprocess not ready,
// timeout, empty tool list), the existing warm cache must remain intact so
// the next startup still enriches flags from the last good snapshot.
func TestRefreshStdioToolsCache_FailurePreservesCache(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
p, sc := newOverlayFixture(t, "refresh-plugin", "refresh-plugin", market.CLIOverlay{
ID: "refresh-plugin",
Command: "refresh-plugin",
})
store := cache.NewStore(t.TempDir())
cacheKey := pluginCacheKey(p.Manifest.Name, sc.Key)
goodSnapshot := cache.ToolsSnapshot{
SavedAt: time.Now().UTC(),
ServerKey: cacheKey,
Tools: []transport.ToolDescriptor{
{
Name: "ping",
Description: "Health check",
InputSchema: map[string]any{"type": "object"},
},
},
}
if err := store.SaveTools(config.DefaultPartition, cacheKey, goodSnapshot); err != nil {
t.Fatalf("seed SaveTools: %v", err)
}
// /usr/bin/true exits immediately, so Initialize + ListTools will fail
// (no MCP handshake). discoverStdioTools returns nil → refresh must be
// a no-op and must NOT overwrite the good cache with a null snapshot.
refreshStdioToolsCache(p, sc, store, pluginColdTimeouts{stdio: 200 * time.Millisecond})
got, _, err := store.LoadTools(config.DefaultPartition, cacheKey)
if err != nil {
t.Fatalf("LoadTools after failed refresh: %v", err)
}
if len(got.Tools) != 1 || got.Tools[0].Name != "ping" {
t.Errorf("warm cache was overwritten by failed refresh: %+v", got.Tools)
}
}
// TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery is an integration-style
// test for the loadPlugins split decision: stdio plugins whose overlay ships
// ToolOverrides must have their commands visible on the root immediately,
// WITHOUT waiting on any discovery handshake. It drives the same sequence
// loadPlugins uses (registerStdioServerFromOverlay → root.AddCommand →
// hideNonDirectRuntimeCommands) and asserts the plugin command survives the
// visibility filter even when no discovery has run.
func TestLoadPlugins_OverlayFirstVisibleBeforeDiscovery(t *testing.T) {
withCleanDynamicRegistry(t)
withCleanStdioRegistry(t)
// Simulate a wukong-like edition that declares a static VisibleProducts
// whitelist NOT containing our plugin. This is the exact scenario where
// the original bug surfaced.
overrideVisibleProducts(t, []string{"calendar", "doc"})
overlay := market.CLIOverlay{
ID: "conference-local",
Command: "conference-local",
ToolOverrides: map[string]market.CLIToolOverride{
"create_meeting": {CLIName: "create", Description: "Create a meeting"},
},
}
p, sc := newOverlayFixture(t, "conference-local", "conference-local", overlay)
// No discovery runs — no cache seeded. This mirrors a cold-start where
// the subprocess is unavailable (or just slow) yet the user expects
// `dws --help` to still list the plugin.
store := cache.NewStore(t.TempDir())
cmds, _, ok := registerStdioServerFromOverlay(p, sc, executor.EchoRunner{}, store)
if !ok {
t.Fatal("registerStdioServerFromOverlay returned ok=false")
}
root := &cobra.Command{Use: "dws"}
// Also add a sibling command that is NOT a registered product so we can
// prove the visibility filter still hides non-product commands.
bogus := &cobra.Command{Use: "bogus-not-a-product"}
root.AddCommand(bogus)
for _, c := range cmds {
root.AddCommand(c)
}
hideNonDirectRuntimeCommands(root)
var pluginCmd *cobra.Command
for _, c := range root.Commands() {
if c.Name() == "conference-local" {
pluginCmd = c
}
}
if pluginCmd == nil {
t.Fatal("conference-local missing from root after overlay-first registration")
}
if pluginCmd.Hidden {
t.Error("conference-local must stay visible (Hidden=false) after hideNonDirectRuntimeCommands")
}
if !bogus.Hidden {
t.Error("bogus-not-a-product must be hidden by the visibility filter")
}
services := visibleMCPRootCommands(root)
if !containsCommand(services, "conference-local") {
t.Errorf("visibleMCPRootCommands missing conference-local: %v", commandNames(services))
}
}
// TestHasOverlayToolOverrides exercises the split-decision helper used by
// loadPlugins to route stdio entries to overlay-first vs. legacy buckets.
func TestHasOverlayToolOverrides(t *testing.T) {
cases := []struct {
name string
overlay market.CLIOverlay
want bool
}{
{
name: "empty overlay",
overlay: market.CLIOverlay{ID: "x", Command: "x"},
want: false,
},
{
name: "overlay with overrides",
overlay: market.CLIOverlay{
ID: "x",
Command: "x",
ToolOverrides: map[string]market.CLIToolOverride{
"foo": {CLIName: "foo"},
},
},
want: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p, sc := newOverlayFixture(t, "x", "x", tc.overlay)
got := hasOverlayToolOverrides(p, sc)
if got != tc.want {
t.Errorf("hasOverlayToolOverrides = %v, want %v", got, tc.want)
}
})
}
}
+82
View File
@@ -0,0 +1,82 @@
package app
import (
"os"
"reflect"
"testing"
)
func TestNormalizeProfileFlagArgsAcceptsUnquotedCommaContinuation(t *testing.T) {
cases := []struct {
name string
args []string
want []string
}{
{
name: "root profile before command",
args: []string{"--mock", "--profile", "corpA,", "corpB", "contact", "user", "get-self"},
want: []string{"--mock", "--profile", "corpA,corpB", "contact", "user", "get-self"},
},
{
name: "profile after leaf command",
args: []string{"contact", "user", "get-self", "--profile", "corpA,", "corpB", "--format", "json"},
want: []string{"contact", "user", "get-self", "--profile", "corpA,corpB", "--format", "json"},
},
{
name: "equals form",
args: []string{"--profile=corpA,", "corpB", "contact", "user", "get-self"},
want: []string{"--profile=corpA,corpB", "contact", "user", "get-self"},
},
{
name: "three profiles",
args: []string{"--profile", "corpA,", "corpB,", "corpC", "contact", "user", "get-self"},
want: []string{"--profile", "corpA,corpB,corpC", "contact", "user", "get-self"},
},
{
name: "already quoted by shell remains unchanged",
args: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
want: []string{"--profile", "corpA, corpB", "contact", "user", "get-self"},
},
{
name: "single profile remains unchanged",
args: []string{"--profile", "corpA", "contact", "user", "get-self"},
want: []string{"--profile", "corpA", "contact", "user", "get-self"},
},
{
name: "trailing comma before next flag remains validation input",
args: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
want: []string{"--profile", "corpA,", "--format", "json", "contact", "user", "get-self"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, _ := normalizeProfileFlagArgs(tc.args)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("normalizeProfileFlagArgs() = %#v, want %#v", got, tc.want)
}
})
}
}
func TestPreparseProfileFlagUsesNormalizedProfileArgs(t *testing.T) {
got := preparseProfileFlag([]string{"--profile", "corpA,", "corpB", "contact", "user", "get-self"})
if got != "corpA,corpB" {
t.Fatalf("preparseProfileFlag() = %q, want corpA,corpB", got)
}
}
func TestNormalizeProcessProfileArgsRestoresOriginalArgv(t *testing.T) {
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
os.Args = []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}
restore := normalizeProcessProfileArgs()
if want := []string{"dws", "--profile", "corpA,corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
t.Fatalf("os.Args after normalize = %#v, want %#v", os.Args, want)
}
restore()
if want := []string{"dws", "--profile", "corpA,", "corpB", "contact", "user", "get-self"}; !reflect.DeepEqual(os.Args, want) {
t.Fatalf("os.Args after restore = %#v, want %#v", os.Args, want)
}
}
+747
View File
@@ -0,0 +1,747 @@
// 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 (
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/muesli/termenv"
"github.com/spf13/cobra"
)
func newProfileCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "profile",
Short: "组织 profile 管理",
Long: `管理本机已登录的钉钉组织 profile。
每个 profile 对应一个已授权组织。业务命令可通过全局 --profile 临时指定组织,
profile switch/use 才会持久修改默认组织上下文。`,
Example: ` dws profile list
dws profile switch
dws profile switch <corpId>
dws profile switch -
dws --profile <corpId> contact user get-self`,
Args: cobra.NoArgs,
TraverseChildren: true,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newProfileListCommand(), newProfileSwitchCommand(), newProfileUseCommand())
return cmd
}
func newProfileListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Aliases: []string{"ls"},
Short: "列出已登录组织 profile",
Long: "列出本机已登录的所有组织 profile,包含当前组织、主组织、组织名、corpId、状态和用户信息。",
Example: ` dws profile list
dws profile list --format json`,
Args: cobra.NoArgs,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") {
return writeProfileListJSON(cmd.OutOrStdout(), cfg)
}
writeProfileListTable(cmd.OutOrStdout(), cfg)
return nil
},
}
}
func newProfileUseCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "use [name|corpId|-]",
Short: "切换当前组织 profile(兼容 profile switch)",
Long: "兼容命令,语义等同于 dws profile switch。可用组织名、profile 名、corpId 或 - 切回上一个组织。",
Example: ` dws profile use <corpId>
dws profile use --name "钉钉"
dws profile use -`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runProfileSwitchCommand(cmd, args)
},
}
addProfileSwitchSelectorFlags(cmd)
return cmd
}
func newProfileSwitchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "switch [name|corpId|-]",
Short: "切换当前组织 profile",
Long: `切换默认组织 profile,并记录 previousProfile 以支持 dws profile switch - 快速切回。
不带参数时,交互终端会展示组织选择器;非交互环境请显式传入组织名、profile 名或 corpId。
需要只影响单次业务命令时,请使用全局 --profile。`,
Example: ` dws profile switch
dws profile switch <corpId>
dws profile switch --corpId <corpId>
dws profile switch --name "钉钉"
dws profile switch -
dws --profile <corpId> contact user get-self`,
Args: cobra.MaximumNArgs(1),
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
return runProfileSwitchCommand(cmd, args)
},
}
addProfileSwitchSelectorFlags(cmd)
return cmd
}
func addProfileSwitchSelectorFlags(cmd *cobra.Command) {
cmd.Flags().String("corpId", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corp-id", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corpid", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("corp", "", "按 corpId 直接切换组织 profile")
cmd.Flags().String("name", "", "按组织名或 profile 名直接切换组织 profile")
_ = cmd.Flags().MarkHidden("corp-id")
_ = cmd.Flags().MarkHidden("corpid")
_ = cmd.Flags().MarkHidden("corp")
}
var (
profileSwitchSelector = selectProfileSwitchProfile
profileSwitchInteractiveTerminal = isInteractiveTerminal
)
const (
profileSwitchVisibleOptions = 5
profileSwitchCellPadding = 1
profileSwitchOrgWidth = 34
profileSwitchStatusWidth = 10
)
var profileSwitchRenderer = newProfileSwitchRenderer()
func newProfileSwitchRenderer() *lipgloss.Renderer {
renderer := lipgloss.NewRenderer(io.Discard)
renderer.SetColorProfile(termenv.TrueColor)
renderer.SetHasDarkBackground(true)
return renderer
}
func runProfileSwitchCommand(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
selector, err := profileSwitchSelectorFromCommand(cmd, args)
if err != nil {
return err
}
usedTUI := false
if selector == "" {
selector, err = profileSwitchSelector(cmd, configDir)
if err != nil {
return err
}
usedTUI = true
}
return switchProfileAndWrite(cmd, configDir, selector, usedTUI)
}
func profileSwitchSelectorFromCommand(cmd *cobra.Command, args []string) (string, error) {
selectors := make([]string, 0, 2)
if len(args) > 0 {
selectors = append(selectors, strings.TrimSpace(args[0]))
}
for _, name := range []string{"corpId", "corp-id", "corpid", "corp", "name"} {
value, changed := changedStringFlag(cmd, name)
if !changed {
continue
}
if value == "" {
return "", apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", name))
}
selectors = append(selectors, value)
}
if len(selectors) == 0 {
return "", nil
}
selector := selectors[0]
for _, candidate := range selectors[1:] {
if candidate != selector {
return "", apperrors.NewValidation("只能指定一个组织选择器,请使用位置参数或 --corpId/--name 其中一种")
}
}
return selector, nil
}
func changedStringFlag(cmd *cobra.Command, name string) (string, bool) {
if cmd == nil || cmd.Flags() == nil {
return "", false
}
flag := cmd.Flags().Lookup(name)
if flag == nil || !flag.Changed {
return "", false
}
return strings.TrimSpace(flag.Value.String()), true
}
func switchProfileAndWrite(cmd *cobra.Command, configDir, selector string, usedTUI bool) error {
var (
profile *authpkg.Profile
err error
)
if strings.TrimSpace(selector) == "-" {
profile, err = authpkg.UsePreviousProfile(configDir)
} else {
profile, err = authpkg.SetCurrentProfile(configDir, selector)
}
if err != nil {
return apperrors.NewValidation(err.Error())
}
ResetRuntimeTokenCache()
clearCompatCache()
format, _ := cmd.Root().PersistentFlags().GetString("format")
if strings.EqualFold(strings.TrimSpace(format), "json") && !(usedTUI && authLoginAllowsInteractiveDefault(cmd, format)) {
cfg, loadErr := authpkg.LoadProfiles(configDir)
if loadErr != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", loadErr))
}
return writeProfileUseJSON(cmd.OutOrStdout(), profile, cfg)
}
fmt.Fprintln(cmd.OutOrStdout(), profileUseMessage(profile))
return nil
}
func selectProfileSwitchProfile(cmd *cobra.Command, configDir string) (string, error) {
if !profileSwitchInteractiveTerminal() {
return "", apperrors.NewValidation("profile selector required in non-interactive mode; use dws profile switch <name|corpId>")
}
if err := authpkg.EnsureProfilesMigration(configDir); err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to migrate profiles: %v", err))
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
return "", apperrors.NewInternal(fmt.Sprintf("failed to load profiles: %v", err))
}
if cfg == nil || len(cfg.Profiles) == 0 {
return "", apperrors.NewValidation("未找到已登录 profile,请先运行 dws auth login")
}
choice := strings.TrimSpace(cfg.CurrentProfile)
if choice == "" {
choice = strings.TrimSpace(cfg.PrimaryProfile)
}
if choice == "" {
choice = cfg.Profiles[0].CorpID
}
return runProfileSwitchTUI(cmd, cfg, choice)
}
func runProfileSwitchTUI(cmd *cobra.Command, cfg *authpkg.ProfilesConfig, selectedCorpID string) (string, error) {
model := newProfileSwitchTUIModel(cfg, selectedCorpID)
program := tea.NewProgram(
model,
tea.WithAltScreen(),
tea.WithInput(cmd.InOrStdin()),
tea.WithOutput(cmd.ErrOrStderr()),
tea.WithContext(cmd.Context()),
)
finalModel, err := program.Run()
if err != nil {
if errors.Is(err, tea.ErrInterrupted) {
return "", apperrors.NewValidation("组织选择中止: user aborted")
}
return "", apperrors.NewInternal(fmt.Sprintf("failed to run profile selector: %v", err))
}
final, ok := finalModel.(profileSwitchTUIModel)
if !ok || final.aborted || !final.submitted {
return "", apperrors.NewValidation("组织选择中止: user aborted")
}
return final.selectedCorpID(), nil
}
type profileSwitchTUIModel struct {
cfg *authpkg.ProfilesConfig
profiles []authpkg.Profile
selected int
offset int
submitted bool
aborted bool
}
func newProfileSwitchTUIModel(cfg *authpkg.ProfilesConfig, selectedCorpID string) profileSwitchTUIModel {
model := profileSwitchTUIModel{cfg: cfg}
if cfg != nil {
model.profiles = profileSwitchSortedProfiles(cfg.Profiles)
}
model.selected = profileSwitchProfileIndex(model.profiles, selectedCorpID)
if model.selected < 0 {
model.selected = 0
}
model.ensureSelectedVisible()
return model
}
func profileSwitchSortedProfiles(profiles []authpkg.Profile) []authpkg.Profile {
sorted := append([]authpkg.Profile(nil), profiles...)
sort.SliceStable(sorted, func(i, j int) bool {
left, leftOK := profileSwitchSortTime(sorted[i])
right, rightOK := profileSwitchSortTime(sorted[j])
if leftOK && rightOK && !left.Equal(right) {
return left.After(right)
}
if leftOK != rightOK {
return leftOK
}
return false
})
return sorted
}
func profileSwitchSortTime(p authpkg.Profile) (time.Time, bool) {
for _, raw := range []string{p.LastLoginAt, p.UpdatedAt, p.LastUsedAt} {
if t, ok := parseProfileSwitchTime(raw); ok {
return t, true
}
}
return time.Time{}, false
}
func parseProfileSwitchTime(raw string) (time.Time, bool) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, false
}
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
return time.Time{}, false
}
return t, true
}
func (m profileSwitchTUIModel) Init() tea.Cmd {
return nil
}
func (m profileSwitchTUIModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "ctrl+c", "esc", "q":
m.aborted = true
return m, tea.Quit
case "up", "k":
if m.selected > 0 {
m.selected--
m.ensureSelectedVisible()
}
case "down", "j":
if m.selected < len(m.profiles)-1 {
m.selected++
m.ensureSelectedVisible()
}
case "enter":
m.submitted = true
return m, tea.Quit
}
}
return m, nil
}
func (m profileSwitchTUIModel) View() string {
var b strings.Builder
title := profileSwitchTitleStyle().Render("选择要切换的组织")
hint := profileSwitchMutedStyle().Render("全部已登录 profile,↑↓ 选择,Enter 确认")
b.WriteString(title)
b.WriteString("\n")
b.WriteString(hint)
b.WriteString("\n\n")
b.WriteString(m.tableView())
b.WriteString("\n")
b.WriteString(profileSwitchMutedStyle().Render("↑/k up • ↓/j down • enter submit • esc cancel"))
return b.String()
}
func (m profileSwitchTUIModel) tableView() string {
rows := []string{
profileSwitchBorder("┌", "┬", "┐"),
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
profileSwitchBorder("├", "┼", "┤"),
}
for i := 0; i < profileSwitchVisibleOptions; i++ {
idx := m.offset + i
if idx >= 0 && idx < len(m.profiles) {
rows = append(rows, m.profileRow(idx))
continue
}
rows = append(rows, profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()))
}
rows = append(rows, profileSwitchBorder("└", "┴", "┘"))
return strings.Join(rows, "\n")
}
func (m profileSwitchTUIModel) profileRow(idx int) string {
profile := m.profiles[idx]
org, status := profileSwitchProfileCells(profile, m.cfg)
style := profileSwitchNormalRowStyle()
if idx == m.selected {
org = "› " + org
style = profileSwitchSelectedRowStyle()
} else {
org = " " + org
}
return profileSwitchStyledTableLine(org, status, style)
}
func (m *profileSwitchTUIModel) ensureSelectedVisible() {
if len(m.profiles) == 0 {
m.selected = 0
m.offset = 0
return
}
if m.selected < 0 {
m.selected = 0
}
if m.selected >= len(m.profiles) {
m.selected = len(m.profiles) - 1
}
if m.selected < m.offset {
m.offset = m.selected
}
if m.selected >= m.offset+profileSwitchVisibleOptions {
m.offset = m.selected - profileSwitchVisibleOptions + 1
}
maxOffset := len(m.profiles) - profileSwitchVisibleOptions
if maxOffset < 0 {
maxOffset = 0
}
if m.offset > maxOffset {
m.offset = maxOffset
}
if m.offset < 0 {
m.offset = 0
}
}
func (m profileSwitchTUIModel) selectedCorpID() string {
if m.selected < 0 || m.selected >= len(m.profiles) {
return ""
}
return strings.TrimSpace(m.profiles[m.selected].CorpID)
}
func profileSwitchProfileIndex(profiles []authpkg.Profile, corpID string) int {
corpID = strings.TrimSpace(corpID)
for i, p := range profiles {
if strings.TrimSpace(p.CorpID) == corpID {
return i
}
}
return -1
}
func profileSwitchOptionLabel(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
org, status := profileSwitchProfileCells(p, cfg)
if status == "" {
return org
}
return strings.Join([]string{org, status}, " | ")
}
func profileSwitchProfileCells(p authpkg.Profile, cfg *authpkg.ProfilesConfig) (string, string) {
return profileOrgName(p), profileSwitchProfileStatus(p, cfg)
}
func profileSwitchProfileStatus(p authpkg.Profile, cfg *authpkg.ProfilesConfig) string {
if cfg != nil && p.CorpID == cfg.CurrentProfile {
return "当前组织"
}
return ""
}
func profileSwitchBorder(left, sep, right string) string {
segments := []string{
strings.Repeat("─", profileSwitchCellWidth(profileSwitchOrgWidth)),
strings.Repeat("─", profileSwitchCellWidth(profileSwitchStatusWidth)),
}
return profileSwitchBorderStyle().Render(left + strings.Join(segments, sep) + right)
}
func profileSwitchTableLine(org, status string) string {
cells := []string{
profileSwitchTableCell(org, profileSwitchOrgWidth),
profileSwitchTableCell(status, profileSwitchStatusWidth),
}
return "│" + strings.Join(cells, "│") + "│"
}
func profileSwitchStyledTableLine(org, status string, style lipgloss.Style) string {
cells := []string{
style.Render(profileSwitchTableCell(org, profileSwitchOrgWidth)),
style.Render(profileSwitchTableCell(status, profileSwitchStatusWidth)),
}
return profileSwitchTableSeparator() + strings.Join(cells, profileSwitchTableSeparator()) + profileSwitchTableSeparator()
}
func profileSwitchTableSeparator() string {
return profileSwitchBorderStyle().Render("│")
}
func profileSwitchTableCell(value string, width int) string {
clipped := clipProfileDisplayCell(strings.TrimSpace(value), width)
padding := strings.Repeat(" ", profileSwitchCellPadding)
return padding + padProfileDisplayCell(clipped, width) + padding
}
func padProfileDisplayCell(value string, width int) string {
padding := width - lipgloss.Width(value)
if padding < 0 {
padding = 0
}
return value + strings.Repeat(" ", padding)
}
func profileSwitchCellWidth(contentWidth int) int {
return contentWidth + profileSwitchCellPadding*2
}
func profileSwitchSelectedRowStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
}
func profileSwitchNormalRowStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#FFFFFF"))
}
func profileSwitchHeaderStyle() lipgloss.Style {
return profileSwitchMutedStyle().Bold(true)
}
func profileSwitchBorderStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#2F3B52"))
}
func profileSwitchTitleStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#69B1FF")).Bold(true)
}
func profileSwitchMutedStyle() lipgloss.Style {
return lipgloss.NewStyle().Renderer(profileSwitchRenderer).Foreground(lipgloss.Color("#8A96A8"))
}
type profileListResponse struct {
Success bool `json:"success"`
PrimaryProfile string `json:"primaryProfile,omitempty"`
CurrentProfile string `json:"currentProfile,omitempty"`
PreviousProfile string `json:"previousProfile,omitempty"`
Profiles []profileView `json:"profiles"`
}
type profileUseResponse struct {
Success bool `json:"success"`
Profile profileView `json:"profile"`
}
type profileView struct {
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
UserID string `json:"userId,omitempty"`
UserName string `json:"userName,omitempty"`
ClientID string `json:"clientId,omitempty"`
Status string `json:"status,omitempty"`
AuthorizedDomains []string `json:"authorizedDomains,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
RefreshExpAt string `json:"refreshExpAt,omitempty"`
LastLoginAt string `json:"lastLoginAt,omitempty"`
LastUsedAt string `json:"lastUsedAt,omitempty"`
IsPrimary bool `json:"isPrimary"`
IsCurrent bool `json:"isCurrent"`
}
func writeProfileListJSON(w io.Writer, cfg *authpkg.ProfilesConfig) error {
resp := profileListResponse{
Success: true,
PrimaryProfile: cfg.PrimaryProfile,
CurrentProfile: cfg.CurrentProfile,
PreviousProfile: cfg.PreviousProfile,
Profiles: profileViews(cfg),
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func writeProfileUseJSON(w io.Writer, profile *authpkg.Profile, cfg *authpkg.ProfilesConfig) error {
resp := profileUseResponse{Success: true}
if profile != nil {
primaryProfile := ""
currentProfile := ""
if cfg != nil {
primaryProfile = cfg.PrimaryProfile
currentProfile = cfg.CurrentProfile
}
resp.Profile = profileViewFromProfile(*profile, primaryProfile, currentProfile)
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(resp)
}
func writeProfileListTable(w io.Writer, cfg *authpkg.ProfilesConfig) {
if cfg == nil || len(cfg.Profiles) == 0 {
fmt.Fprintln(w, "未找到已登录 profile")
return
}
fmt.Fprintf(w, "%-3s %-3s %-28s %-34s %-10s %s\n", "CUR", "PRI", "ORG_NAME", "CORP_ID", "STATUS", "USER")
for _, p := range cfg.Profiles {
current := ""
if p.CorpID == cfg.CurrentProfile {
current = "*"
}
primary := ""
if p.CorpID == cfg.PrimaryProfile {
primary = "*"
}
user := p.UserName
if user == "" {
user = p.UserID
}
status := p.Status
if status == "" {
status = authpkg.ProfileStatusActive
}
fmt.Fprintf(
w,
"%-3s %-3s %-28s %-34s %-10s %s\n",
current,
primary,
clipProfileCell(profileOrgName(p), 28),
clipProfileCell(p.CorpID, 34),
status,
user,
)
}
}
func profileUseMessage(profile *authpkg.Profile) string {
if profile == nil {
return "[OK] 当前 profile 已切换"
}
corpID := strings.TrimSpace(profile.CorpID)
orgName := strings.TrimSpace(profile.CorpName)
if orgName == "" {
orgName = profileOrgName(*profile)
}
return fmt.Sprintf("[OK] 当前组织: %s (%s)", orgName, corpID)
}
func profileOrgName(p authpkg.Profile) string {
if v := strings.TrimSpace(p.CorpName); v != "" {
return v
}
if v := strings.TrimSpace(p.Name); v != "" {
return v
}
return strings.TrimSpace(p.CorpID)
}
func profileViews(cfg *authpkg.ProfilesConfig) []profileView {
if cfg == nil {
return nil
}
views := make([]profileView, 0, len(cfg.Profiles))
for _, p := range cfg.Profiles {
views = append(views, profileViewFromProfile(p, cfg.PrimaryProfile, cfg.CurrentProfile))
}
return views
}
func profileViewFromProfile(p authpkg.Profile, primaryProfile, currentProfile string) profileView {
return profileView{
CorpID: p.CorpID,
CorpName: profileOrgName(p),
UserID: p.UserID,
UserName: p.UserName,
ClientID: p.ClientID,
Status: p.Status,
AuthorizedDomains: p.AuthorizedDomains,
ExpiresAt: p.ExpiresAt,
RefreshExpAt: p.RefreshExpAt,
LastLoginAt: p.LastLoginAt,
LastUsedAt: p.LastUsedAt,
IsPrimary: p.CorpID == primaryProfile,
IsCurrent: p.CorpID == currentProfile,
}
}
func clipProfileCell(value string, limit int) string {
if limit <= 0 {
return ""
}
runes := []rune(value)
if len(runes) <= limit {
return value
}
if limit <= 3 {
return string(runes[:limit])
}
return string(runes[:limit-3]) + "..."
}
func clipProfileDisplayCell(value string, limit int) string {
if limit <= 0 {
return ""
}
if lipgloss.Width(value) <= limit {
return value
}
if limit <= 3 {
var b strings.Builder
for _, r := range value {
rw := lipgloss.Width(string(r))
if lipgloss.Width(b.String())+rw > limit {
break
}
b.WriteRune(r)
}
return b.String()
}
target := limit - 3
var b strings.Builder
width := 0
for _, r := range value {
rw := lipgloss.Width(string(r))
if width+rw > target {
break
}
b.WriteRune(r)
width += rw
}
return b.String() + "..."
}
+582
View File
@@ -0,0 +1,582 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"encoding/json"
"fmt"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
func TestWriteProfileUseJSONKeepsPrimaryAndCurrentDistinct(t *testing.T) {
profile := &authpkg.Profile{
Name: "B Org",
CorpID: "corp_b",
CorpName: "B Org",
Status: authpkg.ProfileStatusActive,
}
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_a",
CurrentProfile: "corp_b",
}
var buf bytes.Buffer
if err := writeProfileUseJSON(&buf, profile, cfg); err != nil {
t.Fatalf("writeProfileUseJSON() error = %v", err)
}
var resp profileUseResponse
if err := json.Unmarshal(buf.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if bytes.Contains(buf.Bytes(), []byte(`"name"`)) {
t.Fatalf("profile use JSON should not contain name when corpName is present:\n%s", buf.String())
}
if resp.Profile.CorpName != "B Org" {
t.Fatalf("corpName = %q, want B Org", resp.Profile.CorpName)
}
if !resp.Profile.IsCurrent {
t.Fatalf("isCurrent = false, want true")
}
if resp.Profile.IsPrimary {
t.Fatalf("isPrimary = true, want false")
}
}
func TestProfileListRootCommandJSONIncludesCorpName(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "json", "profile", "list"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile list --format json error = %v\noutput:\n%s", err, out.String())
}
var resp profileListResponse
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v\noutput:\n%s", err, out.String())
}
if !resp.Success {
t.Fatal("success = false, want true")
}
if resp.PrimaryProfile != "corp_primary" || resp.CurrentProfile != "corp_secondary" || resp.PreviousProfile != "corp_primary" {
t.Fatalf("profile pointers = primary %q current %q previous %q, want corp_primary/corp_secondary/corp_primary", resp.PrimaryProfile, resp.CurrentProfile, resp.PreviousProfile)
}
if len(resp.Profiles) != 2 {
t.Fatalf("profiles len = %d, want 2", len(resp.Profiles))
}
if bytes.Contains(out.Bytes(), []byte(`"name"`)) {
t.Fatalf("profile list JSON should not contain name when corpName is present:\n%s", out.String())
}
for _, p := range resp.Profiles {
if p.CorpName == "" {
t.Fatalf("profile %s missing corpName in JSON response: %#v", p.CorpID, p)
}
}
}
func TestProfileUseRootCommandSwitchesOrganizationAndLegacyMirror(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use corp_primary error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile use output should include organization name:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_primary" {
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
}
cmd = NewRootCommand()
out.Reset()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use - error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_secondary org")) {
t.Fatalf("profile use - output should include organization name:\n%s", out.String())
}
cfg, err = authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" || cfg.PreviousProfile != "corp_primary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_secondary/corp_primary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err = authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_secondary" {
t.Fatalf("legacy token corp = %q, want corp_secondary", legacyToken.CorpID)
}
}
func TestProfileSwitchRootCommandSwitchesPrimaryOrganizationAndLegacyMirror(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch corp_primary error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile switch output should include organization name:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" || cfg.PreviousProfile != "corp_secondary" {
t.Fatalf("profile pointers = current %q previous %q, want corp_primary/corp_secondary", cfg.CurrentProfile, cfg.PreviousProfile)
}
legacyToken, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if legacyToken.CorpID != "corp_primary" {
t.Fatalf("legacy token corp = %q, want corp_primary", legacyToken.CorpID)
}
}
func TestProfileSwitchRootCommandSupportsCorpIDFlag(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "switch", "--corpId", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch --corpId error = %v\noutput:\n%s", err, out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
cmd = NewRootCommand()
out.Reset()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "profile", "use", "--corp", "corp_secondary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use --corp error = %v\noutput:\n%s", err, out.String())
}
cfg, err = authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("currentProfile = %q, want corp_secondary", cfg.CurrentProfile)
}
}
func TestProfileSwitchRootCommandRejectsConflictingSelectors(t *testing.T) {
setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "switch", "corp_primary", "--corpId", "corp_secondary"})
err := cmd.Execute()
if err == nil {
t.Fatalf("profile switch with conflicting selectors succeeded\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), "只能指定一个组织选择器") {
t.Fatalf("error = %v, want conflicting selector validation", err)
}
}
func TestProfileSwitchNoArgsUsesTUISelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
oldSelector := profileSwitchSelector
t.Cleanup(func() {
profileSwitchSelector = oldSelector
})
called := false
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
called = true
if gotConfigDir != configDir {
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
}
return "corp_primary", nil
}
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "switch"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile switch error = %v\noutput:\n%s", err, out.String())
}
if !called {
t.Fatal("profile switch without args did not invoke TUI selector")
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile switch TUI path should use human output by default:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
}
func TestProfileSwitchOptionLabelUsesOnlyOrganizationAndCurrentState(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_primary",
CurrentProfile: "corp_secondary",
Profiles: []authpkg.Profile{
{
CorpID: "corp_primary",
CorpName: "第一组织",
UserName: "alice",
Status: authpkg.ProfileStatusActive,
},
{
CorpID: "corp_secondary",
CorpName: "第二组织",
UserName: "bob",
Status: authpkg.ProfileStatusActive,
},
},
}
primary := profileSwitchOptionLabel(cfg.Profiles[0], cfg)
current := profileSwitchOptionLabel(cfg.Profiles[1], cfg)
for _, label := range []string{primary, current} {
if strings.Contains(label, "\n") {
t.Fatalf("profile switch label contains newline: %q", label)
}
}
if !strings.Contains(primary, "第一组织") {
t.Fatalf("primary option missing organization name: %q", primary)
}
if !strings.Contains(current, "当前组织") {
t.Fatalf("current option missing current marker: %q", current)
}
for _, unwanted := range []string{"alice", "bob", "已登录", "主组织", "corp_primary", "corp_secondary"} {
if strings.Contains(primary, unwanted) || strings.Contains(current, unwanted) {
t.Fatalf("profile switch option should not contain %q: %q / %q", unwanted, primary, current)
}
}
}
func TestProfileSwitchTUIViewUsesFixedOuterTable(t *testing.T) {
cfg := profileSwitchTestConfig(2)
model := newProfileSwitchTUIModel(cfg, "corp_00")
view := model.tableView()
if lines := strings.Split(view, "\n"); len(lines) != profileSwitchVisibleOptions+4 {
t.Fatalf("table line count = %d, want %d:\n%s", len(lines), profileSwitchVisibleOptions+4, view)
}
for _, want := range []string{"┌", "┬", "┐", "├", "┼", "┤", "└", "┴", "┘", "组织名", "本地状态"} {
if !strings.Contains(view, want) {
t.Fatalf("profile switch table missing %q in:\n%s", want, view)
}
}
for _, unwanted := range []string{"CORP_ID", "ORGANIZATION", "STATUS"} {
if strings.Contains(view, unwanted) {
t.Fatalf("profile switch table should not contain %q:\n%s", unwanted, view)
}
}
if got := strings.Count(view, "│"); got != (profileSwitchVisibleOptions+1)*3 {
t.Fatalf("table vertical separators = %d, want %d\n%s", got, (profileSwitchVisibleOptions+1)*3, view)
}
for _, profile := range cfg.Profiles {
if got := strings.Count(view, profile.CorpID); got != 0 {
t.Fatalf("profile corpId %s appears %d times, want hidden:\n%s", profile.CorpID, got, view)
}
}
}
func TestProfileSwitchTUISortsLatestLoggedInProfilesFirst(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "old",
CurrentProfile: "old",
Profiles: []authpkg.Profile{
{CorpID: "old", CorpName: "旧组织", LastLoginAt: "2026-06-26T10:00:00+08:00"},
{CorpID: "new", CorpName: "新组织", LastLoginAt: "2026-06-26T12:00:00+08:00"},
{CorpID: "fallback", CorpName: "兜底组织", UpdatedAt: "2026-06-26T11:00:00+08:00"},
},
}
model := newProfileSwitchTUIModel(cfg, "old")
gotOrder := []string{model.profiles[0].CorpID, model.profiles[1].CorpID, model.profiles[2].CorpID}
wantOrder := []string{"new", "fallback", "old"}
if strings.Join(gotOrder, ",") != strings.Join(wantOrder, ",") {
t.Fatalf("profile order = %v, want %v", gotOrder, wantOrder)
}
if got := model.selectedCorpID(); got != "old" {
t.Fatalf("selectedCorpID = %q, want old", got)
}
}
func TestProfileSwitchTUIArrowKeysMoveSelectionWithoutDuplicatingRows(t *testing.T) {
cfg := profileSwitchTestConfig(7)
model := newProfileSwitchTUIModel(cfg, "corp_00")
for step := 0; step < 6; step++ {
view := model.tableView()
if got := strings.Count(view, "›"); got != 1 {
t.Fatalf("step %d selected cursor count = %d, want 1:\n%s", step, got, view)
}
for _, profile := range cfg.Profiles {
name := profileOrgName(profile)
if got := strings.Count(view, name); got > 1 {
t.Fatalf("step %d profile %s appears %d times, want at most once:\n%s", step, name, got, view)
}
}
next, _ := model.Update(tea.KeyMsg{Type: tea.KeyDown})
model = next.(profileSwitchTUIModel)
}
if model.selected != 6 || model.offset != 2 {
t.Fatalf("selection after down keys = selected %d offset %d, want 6/2", model.selected, model.offset)
}
}
func TestProfileSwitchTableRowsKeepFixedDisplayWidth(t *testing.T) {
rows := []string{
profileSwitchTableLine("组织名", "本地状态"),
profileSwitchTableLine("› 钉钉(中国)信息技术有限公司", "当前组织"),
profileSwitchTableLine(" ACME", ""),
profileSwitchTableLine("", ""),
profileSwitchStyledTableLine("组织名", "本地状态", profileSwitchHeaderStyle()),
profileSwitchStyledTableLine("› 钉钉(中国)信息技术有限公司", "当前组织", profileSwitchSelectedRowStyle()),
profileSwitchStyledTableLine(" ACME", "", profileSwitchNormalRowStyle()),
profileSwitchStyledTableLine("", "", profileSwitchNormalRowStyle()),
}
wantWidth := lipgloss.Width(rows[0])
for i, row := range rows {
if got := lipgloss.Width(row); got != wantWidth {
t.Fatalf("row[%d] width = %d, want %d: %q", i, got, wantWidth, row)
}
if got := strings.Count(row, "│"); got != 3 {
t.Fatalf("row[%d] separator count = %d, want 3: %q", i, got, row)
}
}
}
func TestProfileSwitchOptionLabelHidesCorpID(t *testing.T) {
const corpID = "ding8196cd9a2b2405da24f2f5cc6abecb85"
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: corpID,
CurrentProfile: corpID,
}
label := profileSwitchOptionLabel(authpkg.Profile{
CorpID: corpID,
CorpName: "钉钉",
}, cfg)
for _, want := range []string{"钉钉", "当前组织"} {
if !strings.Contains(label, want) {
t.Fatalf("profile switch label missing %q in %q", want, label)
}
}
for _, unwanted := range []string{"ding8196", "cb85", "主组织"} {
if strings.Contains(label, unwanted) {
t.Fatalf("profile switch label should not contain %q in %q", unwanted, label)
}
}
}
func profileSwitchTestConfig(count int) *authpkg.ProfilesConfig {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_00",
CurrentProfile: "corp_00",
}
for i := 0; i < count; i++ {
corpID := fmt.Sprintf("corp_%02d", i)
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
CorpID: corpID,
CorpName: fmt.Sprintf("组织%02d", i),
Status: authpkg.ProfileStatusActive,
})
}
return cfg
}
func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "switch"})
err := cmd.Execute()
if err == nil {
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
}
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
t.Fatalf("error = %v, want auth switch unknown command", err)
}
}
func TestProfileUseNoArgsUsesTUISelector(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
oldSelector := profileSwitchSelector
t.Cleanup(func() {
profileSwitchSelector = oldSelector
})
profileSwitchSelector = func(cmd *cobra.Command, gotConfigDir string) (string, error) {
if gotConfigDir != configDir {
t.Fatalf("configDir = %q, want %q", gotConfigDir, configDir)
}
return "corp_primary", nil
}
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"profile", "use"})
if err := cmd.Execute(); err != nil {
t.Fatalf("profile use error = %v\noutput:\n%s", err, out.String())
}
if !bytes.Contains(out.Bytes(), []byte("组织: corp_primary org")) {
t.Fatalf("profile use TUI path should use human output by default:\n%s", out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_primary" {
t.Fatalf("currentProfile = %q, want corp_primary", cfg.CurrentProfile)
}
}
func TestProfileSwitchSelectorRequiresInteractiveTerminal(t *testing.T) {
oldInteractive := profileSwitchInteractiveTerminal
t.Cleanup(func() {
profileSwitchInteractiveTerminal = oldInteractive
})
profileSwitchInteractiveTerminal = func() bool { return false }
_, err := selectProfileSwitchProfile(nil, t.TempDir())
if err == nil {
t.Fatal("selectProfileSwitchProfile() succeeded, want validation error")
}
if !bytes.Contains([]byte(err.Error()), []byte("profile selector required")) {
t.Fatalf("error = %v, want profile selector hint", err)
}
}
func TestWriteProfileListTableIncludesCorpName(t *testing.T) {
cfg := &authpkg.ProfilesConfig{
PrimaryProfile: "corp_a",
CurrentProfile: "corp_b",
Profiles: []authpkg.Profile{
{
Name: "DingTalk China",
CorpID: "corp_a",
CorpName: "钉钉(中国)信息技术有限公司",
UserName: "alice",
Status: authpkg.ProfileStatusActive,
},
{
Name: "B Org",
CorpID: "corp_b",
CorpName: "B 组织",
UserID: "bob-id",
},
},
}
var buf bytes.Buffer
writeProfileListTable(&buf, cfg)
out := buf.String()
for _, want := range []string{
"ORG_NAME",
"钉钉(中国)信息技术有限公司",
"B 组织",
"corp_a",
"corp_b",
} {
if !bytes.Contains(buf.Bytes(), []byte(want)) {
t.Fatalf("profile list table missing %q in output:\n%s", want, out)
}
}
for _, unwanted := range []string{"PROFILE", "DingTalk China"} {
if bytes.Contains(buf.Bytes(), []byte(unwanted)) {
t.Fatalf("profile list table should not contain %q in output:\n%s", unwanted, out)
}
}
}
func TestProfileUseMessageIncludesCorpName(t *testing.T) {
got := profileUseMessage(&authpkg.Profile{
Name: "DingTalk China",
CorpID: "ding8196",
CorpName: "钉钉(中国)信息技术有限公司",
})
for _, want := range []string{"当前组织: 钉钉(中国)信息技术有限公司", "ding8196"} {
if !bytes.Contains([]byte(got), []byte(want)) {
t.Fatalf("profileUseMessage() missing %q in %q", want, got)
}
}
if bytes.Contains([]byte(got), []byte("DingTalk China")) {
t.Fatalf("profileUseMessage() should not include profile name when corpName is present: %q", got)
}
}
-50
View File
@@ -1,50 +0,0 @@
// 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
@@ -293,7 +293,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
Status: "empty",
Request: &recovery.ToolCallRecord{
ServerID: "devdoc",
ToolName: "search_open_platform_docs",
ToolName: "search_open_platform_docs_rag",
Arguments: cloneRecoveryArgs(requestArgs),
},
},
@@ -302,7 +302,7 @@ func (r *recoveryRuntime) Search(ctx context.Context, query string, rc recovery.
retrieval.DocSearch.Status = "skipped"
return retrieval, nil
}
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs", requestArgs)
result, err := r.CallToolDirect(ctx, "devdoc", "search_open_platform_docs_rag", requestArgs)
if result != nil {
retrieval.DocSearch.Response = toRecoveryToolResponse(result)
}
-324
View File
@@ -1,324 +0,0 @@
package app
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
)
func TestRecoveryPlanReadsLastSnapshotAndPrintsJSON(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_test",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
CLIErrorCode: "RESOURCE_NOT_FOUND",
RawError: "resource_not_found",
Fingerprint: "fp-1",
},
Replay: recovery.Replay{
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
ToolArgs: map[string]any{"instanceId": "ins_1"},
RedactedCommand: "dws approval instance get --instance-id ins_1 --format json",
},
})
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"recovery", "plan", "--last", "-f", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(recovery plan) error = %v", err)
}
if !strings.Contains(out.String(), `"event_id": "evt_test"`) {
t.Fatalf("output missing event id:\n%s", out.String())
}
if !strings.Contains(out.String(), `"category": "resource"`) {
t.Fatalf("output missing resource category:\n%s", out.String())
}
}
func TestRecoveryExecuteReadsLastSnapshotAndPrintsJSON(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_exec",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
CLIErrorCode: "RESOURCE_NOT_FOUND",
RawError: "resource_not_found",
Fingerprint: "fp-2",
},
Replay: recovery.Replay{
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
ToolArgs: map[string]any{"instanceId": "ins_1"},
RedactedCommand: "dws approval instance get --instance-id ins_1 --format json",
},
})
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"recovery", "execute", "--last", "-f", "json"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(recovery execute) error = %v", err)
}
if !strings.Contains(out.String(), `"event_id": "evt_exec"`) {
t.Fatalf("output missing event id:\n%s", out.String())
}
if !strings.Contains(out.String(), `"status": "needs_agent_action"`) {
t.Fatalf("output missing bundle status:\n%s", out.String())
}
}
func TestRecoveryFinalizeRequiresEventIDAndOutcome(t *testing.T) {
root := NewRootCommand()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"recovery", "finalize"})
err := root.Execute()
if err == nil {
t.Fatal("Execute(recovery finalize) error = nil, want validation")
}
if !strings.Contains(err.Error(), "--event-id") {
t.Fatalf("error = %v, want event-id requirement", err)
}
}
func TestRecoveryPlanRejectsLastAndEventIDTogether(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_conflict",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationRead,
CLIErrorCode: "RESOURCE_NOT_FOUND",
RawError: "resource_not_found",
Fingerprint: "fp-conflict",
},
})
root := NewRootCommand()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"recovery", "plan", "--last", "--event-id", "evt_conflict"})
err := root.Execute()
if err == nil {
t.Fatal("Execute(recovery plan) error = nil, want conflict validation")
}
if !strings.Contains(err.Error(), "--last") || !strings.Contains(err.Error(), "--event-id") {
t.Fatalf("error = %v, want mutually exclusive flags", err)
}
}
func TestRecoveryFinalizeAcceptsLegacyExecutionFile(t *testing.T) {
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
writeRecoverySnapshot(t, configDir, recovery.LastError{
EventID: "evt_legacy_finalize",
RecordedAt: time.Now().UTC().Format(time.RFC3339Nano),
Context: recovery.RecoveryContext{
CommandPath: []string{"approval", "instance", "get"},
ServerID: "approval",
ToolName: "get_approval_instance",
OperationKind: recovery.OperationUnknown,
RawError: "unexpected upstream failure",
Fingerprint: "fp-legacy-finalize",
},
})
executionPath := filepath.Join(configDir, "legacy_execution.json")
if err := os.WriteFile(executionPath, []byte(`{"action":"verify_resource_exists","attempts":2,"result":"failed","error":"resource still missing"}`), 0o600); err != nil {
t.Fatalf("WriteFile(legacy execution) error = %v", err)
}
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{
"recovery", "finalize",
"--event-id", "evt_legacy_finalize",
"--outcome", "failed",
"--execution-file", executionPath,
"-f", "json",
})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(recovery finalize) error = %v", err)
}
if !strings.Contains(out.String(), `"execution_recorded": true`) {
t.Fatalf("output missing execution_recorded flag:\n%s", out.String())
}
data, err := os.ReadFile(filepath.Join(configDir, "recovery", "recovery_events.jsonl"))
if err != nil {
t.Fatalf("ReadFile(recovery_events.jsonl) error = %v", err)
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
lastLine := lines[len(lines)-1]
if !strings.Contains(lastLine, `"phase":"finalized"`) {
t.Fatalf("expected finalized event, got %s", lastLine)
}
if !strings.Contains(lastLine, `"legacy_execution_file"`) {
t.Fatalf("expected legacy execution attempts to be normalized, got %s", lastLine)
}
}
func TestExecuteWritesRecoveryEventIDToStderrOnCapturedFailure(t *testing.T) {
setupRuntimeCommandTest(t)
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
t.Setenv("DWS_ALLOW_HTTP_ENDPOINTS", "1")
t.Setenv("DWS_TRUSTED_DOMAINS", "*")
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
}
switch req["method"] {
case "initialize":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"protocolVersion": "2025-03-26",
"capabilities": map[string]any{"tools": map[string]any{"listChanged": false}},
"serverInfo": map[string]any{"name": "doc", "version": "1.0.0"},
},
})
case "notifications/initialized":
w.WriteHeader(http.StatusNoContent)
case "tools/list":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"tools": []map[string]any{
{
"name": "search_documents",
"title": "Search",
"description": "Search documents",
"inputSchema": map[string]any{"type": "object"},
},
},
},
})
case "tools/call":
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req["id"],
"result": map[string]any{
"content": []map[string]any{
{
"type": "text",
"text": "baseId is required",
},
},
"isError": true,
},
})
}
}))
defer server.Close()
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.URL, false))
oldArgs := os.Args
defer func() { os.Args = oldArgs }()
os.Args = []string{"dws", "mcp", "doc", "search_documents", "--json", `{"keyword":"design"}`, "--token", "test-token"}
stdoutR, stdoutW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe(stdout) error = %v", err)
}
stderrR, stderrW, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe(stderr) error = %v", err)
}
oldStdout := os.Stdout
oldStderr := os.Stderr
defer func() {
os.Stdout = oldStdout
os.Stderr = oldStderr
}()
os.Stdout = stdoutW
os.Stderr = stderrW
exitCode := Execute()
_ = stdoutW.Close()
_ = stderrW.Close()
stdoutData, _ := io.ReadAll(stdoutR)
stderrData, _ := io.ReadAll(stderrR)
if exitCode == 0 {
t.Fatalf("Execute() exitCode = 0, want failure\nstdout:\n%s\nstderr:\n%s", stdoutData, stderrData)
}
if !strings.Contains(string(stderrData), "RECOVERY_EVENT_ID=evt_") {
t.Fatalf("stderr missing recovery event id:\n%s", stderrData)
}
data, err := os.ReadFile(filepath.Join(configDir, "recovery", "last_error.json"))
if err != nil {
t.Fatalf("ReadFile(last_error.json) error = %v", err)
}
var last recovery.LastError
if err := json.Unmarshal(data, &last); err != nil {
t.Fatalf("json.Unmarshal(last_error) error = %v", err)
}
if last.EventID == "" || last.Context.ToolName != "search_documents" {
t.Fatalf("unexpected recovery snapshot %#v", last)
}
}
func writeRecoverySnapshot(t *testing.T, configDir string, last recovery.LastError) {
t.Helper()
recoveryDir := filepath.Join(configDir, "recovery")
if err := os.MkdirAll(recoveryDir, 0o700); err != nil {
t.Fatalf("MkdirAll(recovery) error = %v", err)
}
data, err := json.MarshalIndent(last, "", " ")
if err != nil {
t.Fatalf("json.MarshalIndent() error = %v", err)
}
if err := os.WriteFile(filepath.Join(recoveryDir, "last_error.json"), append(data, '\n'), 0o600); err != nil {
t.Fatalf("WriteFile(last_error.json) error = %v", err)
}
}
+205 -864
View File
File diff suppressed because it is too large Load Diff
-254
View File
@@ -1,254 +0,0 @@
package app
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func TestCacheRefreshClearsExistingCachesAndSkipsCLISkippedServers(t *testing.T) {
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
var skippedRuntimeCalls atomic.Int32
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/cli/discovery/apis":
_ = json.NewEncoder(w).Encode(market.ListResponse{
Metadata: market.ListMetadata{Count: 2},
Servers: []market.ServerEnvelope{
{
Server: market.RegistryServer{
Name: "Active Service",
Remotes: []market.RegistryRemote{
{Type: "streamable-http", URL: srv.URL + "/mcp/active"},
},
},
Meta: market.EnvelopeMeta{
Registry: market.RegistryMetadata{Status: "active"},
CLI: market.CLIOverlay{ID: "active", Command: "active"},
},
},
{
Server: market.RegistryServer{
Name: "Skipped Service",
Remotes: []market.RegistryRemote{
{Type: "streamable-http", URL: srv.URL + "/mcp/skipped"},
},
},
Meta: market.EnvelopeMeta{
Registry: market.RegistryMetadata{Status: "active"},
CLI: market.CLIOverlay{ID: "legacy", Command: "legacy", Skip: true},
},
},
},
})
case "/mcp/active":
http.Error(w, "active runtime unavailable", http.StatusInternalServerError)
case "/mcp/skipped":
skippedRuntimeCalls.Add(1)
http.Error(w, "skipped runtime should not be called", http.StatusInternalServerError)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
store := cache.NewStore(cacheDir)
const partition = "default/default"
activeKey := market.ServerKey(srv.URL + "/mcp/active")
skippedKey := market.ServerKey(srv.URL + "/mcp/skipped")
saveCachedRuntimeAndDetail(t, store, partition, activeKey)
saveCachedRuntimeAndDetail(t, store, partition, skippedKey)
saveCLIIDDetail(t, store, partition, "active")
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmd := newCacheCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"refresh"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if _, _, err := store.LoadTools(partition, activeKey); err == nil {
t.Fatal("LoadTools(active) error = nil, want cache cleared before failed refresh")
}
if _, _, err := store.LoadDetail(partition, activeKey); err == nil {
t.Fatal("LoadDetail(active) error = nil, want detail cache cleared before failed refresh")
}
if _, _, err := store.LoadDetail(partition, "active"); err != nil {
t.Fatalf("LoadDetail(active CLI.ID) error = %v, want CLI metadata preserved on failed refresh", err)
}
if _, _, err := store.LoadTools(partition, skippedKey); err == nil {
t.Fatal("LoadTools(skipped) error = nil, want skipped service cache removed")
}
if _, _, err := store.LoadDetail(partition, skippedKey); err == nil {
t.Fatal("LoadDetail(skipped) error = nil, want skipped service detail cache removed")
}
if got := skippedRuntimeCalls.Load(); got != 0 {
t.Fatalf("skipped runtime calls = %d, want 0", got)
}
}
// TestCacheRefreshHonorsEditionDiscoveryURL asserts the `dws cache refresh`
// command routes its server-list fetch through edition.Hooks.DiscoveryURL /
// DiscoveryHeaders when they are set, instead of the default Market endpoint.
// Kept deliberately generic (no edition-specific strings) — concrete values
// belong to the overlay repo that installs the hooks, not to this open core.
func TestCacheRefreshHonorsEditionDiscoveryURL(t *testing.T) {
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
var (
editionHits atomic.Int32
marketHits atomic.Int32
gotHeaders atomic.Value // map[string]string
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/cli/edition/apis":
editionHits.Add(1)
snapshot := map[string]string{
"x-test-edition": r.Header.Get("x-test-edition"),
"x-test-client": r.Header.Get("x-test-client"),
}
gotHeaders.Store(snapshot)
_ = json.NewEncoder(w).Encode(market.ListResponse{
Metadata: market.ListMetadata{Count: 1},
Servers: []market.ServerEnvelope{
{
Server: market.RegistryServer{
Name: "Edition Service",
Remotes: []market.RegistryRemote{{Type: "streamable-http", URL: "https://example.invalid/mcp"}},
},
Meta: market.EnvelopeMeta{
Registry: market.RegistryMetadata{Status: "active"},
CLI: market.CLIOverlay{ID: "edition-service", Command: "edition-service"},
},
},
},
})
case "/cli/discovery/apis":
marketHits.Add(1)
http.Error(w, "market endpoint must not be called when edition DiscoveryURL is set", http.StatusNotFound)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
edition.Override(&edition.Hooks{
Name: "testing",
DiscoveryURL: srv.URL + "/cli/edition/apis",
DiscoveryHeaders: func() map[string]string {
return map[string]string{
"x-test-edition": "custom",
"x-test-client": "cli-refresh",
}
},
})
t.Cleanup(func() { edition.Override(&edition.Hooks{}) })
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
cmd := newCacheCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"refresh"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if got := editionHits.Load(); got != 1 {
t.Fatalf("edition DiscoveryURL hits = %d, want 1", got)
}
if got := marketHits.Load(); got != 0 {
t.Fatalf("market endpoint hits = %d, want 0 (edition DiscoveryURL must take precedence)", got)
}
headers, _ := gotHeaders.Load().(map[string]string)
if headers == nil {
t.Fatal("captured request headers = nil, want edition DiscoveryHeaders to be applied")
}
if headers["x-test-edition"] != "custom" {
t.Fatalf("x-test-edition header = %q, want %q", headers["x-test-edition"], "custom")
}
if headers["x-test-client"] != "cli-refresh" {
t.Fatalf("x-test-client header = %q, want %q", headers["x-test-client"], "cli-refresh")
}
}
func saveCLIIDDetail(t *testing.T, store *cache.Store, partition, cliID string) {
t.Helper()
payload, err := json.Marshal(market.DetailResponse{
Success: true,
Result: market.DetailResult{
Tools: []market.DetailTool{
{ToolName: "stale_tool", ToolTitle: "Stale Tool"},
},
},
})
if err != nil {
t.Fatalf("json.Marshal(cli detail payload) error = %v", err)
}
if err := store.SaveDetail(partition, cliID, cache.DetailSnapshot{
MCPID: 0,
Payload: payload,
}); err != nil {
t.Fatalf("SaveDetail(%s) error = %v", cliID, err)
}
}
func saveCachedRuntimeAndDetail(t *testing.T, store *cache.Store, partition, serverKey string) {
t.Helper()
if err := store.SaveTools(partition, serverKey, cache.ToolsSnapshot{
ServerKey: serverKey,
ProtocolVersion: "2025-03-26",
Tools: []transport.ToolDescriptor{
{Name: "stale_tool", Title: "Stale Tool"},
},
}); err != nil {
t.Fatalf("SaveTools(%s) error = %v", serverKey, err)
}
payload, err := json.Marshal(market.DetailResponse{
Success: true,
Result: market.DetailResult{
Tools: []market.DetailTool{
{ToolName: "stale_tool", ToolTitle: "Stale Tool"},
},
},
})
if err != nil {
t.Fatalf("json.Marshal(detail payload) error = %v", err)
}
if err := store.SaveDetail(partition, serverKey, cache.DetailSnapshot{
MCPID: 0,
Payload: payload,
}); err != nil {
t.Fatalf("SaveDetail(%s) error = %v", serverKey, err)
}
}
-440
View File
@@ -1,440 +0,0 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
"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/market"
mockmcp "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
)
// patLikeError simulates an edition-specific PAT error that implements both
// ExitCoder (exit code 4) and RawStderrError (raw JSON to stderr).
type patLikeError struct{ raw string }
func (e *patLikeError) Error() string { return e.raw }
func (e *patLikeError) ExitCode() int { return 4 }
func (e *patLikeError) RawStderr() string { return e.raw }
func TestPrintExecutionErrorDefaultsToJSON(t *testing.T) {
t.Parallel()
root := NewRootCommand()
var stdout bytes.Buffer
var stderr bytes.Buffer
err := printExecutionError(root, &stdout, &stderr, apperrors.NewValidation(
"bad flag",
apperrors.WithHint("Pass the required flag and retry."),
))
if err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
}
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
}
}
func TestPrintExecutionErrorUsesJSONWhenFormatIsJSON(t *testing.T) {
t.Parallel()
root := NewRootCommand()
if err := root.PersistentFlags().Set("format", "json"); err != nil {
t.Fatalf("Set(format) error = %v", err)
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := printExecutionError(root, &stdout, &stderr, apperrors.NewValidation("bad flag"))
if err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
}
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
}
}
func TestPrintExecutionErrorUsesJSONWhenCommandSetsJSONFlag(t *testing.T) {
setupRuntimeCommandTest(t)
server := mockmcp.DefaultServer()
defer server.Close()
t.Setenv(cli.CatalogFixtureEnv, writeDocCatalogFixture(t, server.RemoteURL("/server/doc"), false))
root := NewRootCommand()
root.SetArgs([]string{"mcp", "doc", "search_documents", "--json", "{"})
executed, execErr := root.ExecuteC()
if execErr == nil {
t.Fatal("ExecuteC() error = nil, want validation error")
}
if executed == nil {
t.Fatal("ExecuteC() returned nil command")
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := printExecutionError(executed, &stdout, &stderr, execErr)
if err != nil {
t.Fatalf("printExecutionError() error = %v", err)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty when errors go to stderr", stdout.String())
}
if !strings.Contains(stderr.String(), "\"category\": \"validation\"") {
t.Fatalf("stderr = %q, want JSON error payload", stderr.String())
}
}
func TestCompletionCommandUsesConfiguredWriter(t *testing.T) {
setupRuntimeCommandTest(t)
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"completion", "bash"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
if !strings.Contains(out.String(), "bash completion for dws") {
t.Fatalf("output = %q, want completion script in configured writer", out.String())
}
}
func TestUnknownSubcommandShowsHelp(t *testing.T) {
t.Parallel()
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"cache", "nonexistent-cmd"})
executed, err := root.ExecuteC()
if err == nil {
t.Fatal("ExecuteC() error = nil, want unknown command error")
}
if !isUnknownCommandError(err) {
t.Fatalf("isUnknownCommandError() = false for error: %v", err)
}
// Simulate what Execute() does: redirect output to stderr and print help
if executed == nil {
executed = root
}
executed.SetOut(&out)
_ = executed.Help()
combined := out.String()
// Help text should include the parent command's usage
if !strings.Contains(combined, "cache") {
t.Fatalf("output should contain parent command name 'cache', got:\n%s", combined)
}
// Help text should list available subcommands
if !strings.Contains(combined, "Available Commands") {
t.Fatalf("output should contain 'Available Commands', got:\n%s", combined)
}
if !strings.Contains(combined, "refresh") {
t.Fatalf("output should list 'refresh' subcommand, got:\n%s", combined)
}
}
func TestVersionCommandDoesNotRequirePINOrLogin(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"version"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(version) error = %v", err)
}
if !strings.Contains(out.String(), "Version:") {
t.Fatalf("version output missing Version line:\n%s", out.String())
}
}
func TestVersionCommandUsesCachedRegistryWithoutBlockingAgedDiscovery(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(cli.CatalogFixtureEnv, "")
cacheDir := t.TempDir()
t.Setenv(cli.CacheDirEnv, cacheDir)
store := cache.NewStore(cacheDir)
if err := store.SaveRegistry("default/default", cache.RegistrySnapshot{
SavedAt: time.Now().UTC().Add(-2 * time.Hour),
Servers: []market.ServerDescriptor{minimalCLIServer("cached", "https://mcp.dingtalk.com/cached/v1")},
}); err != nil {
t.Fatalf("SaveRegistry() error = %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
_ = json.NewEncoder(w).Encode(marketListResponse("network-server"))
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"version"})
start := time.Now()
if err := root.Execute(); err != nil {
t.Fatalf("Execute(version) error = %v", err)
}
if elapsed := time.Since(start); elapsed >= 200*time.Millisecond {
t.Fatalf("Execute(version) took %v, want cached startup under 200ms", elapsed)
}
if !strings.Contains(out.String(), "Version:") {
t.Fatalf("version output missing Version line:\n%s", out.String())
}
}
func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("aiapp", "AI应用管理", nil, map[string]any{
"create_ai_app": map[string]any{
"cliName": "create",
"flags": map[string]any{},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--help"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(--help) error = %v", err)
}
if !strings.Contains(out.String(), "Discovered MCP Services:") {
t.Fatalf("root help output missing MCP summary:\n%s", out.String())
}
for _, want := range []string{"Utility Commands:", "skill", "auth", "version"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("root help output missing %q:\n%s", want, out.String())
}
}
}
func TestRootShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("devdoc", "开放平台文档搜索", map[string]any{
"article": map[string]any{"description": "文档文章"},
}, map[string]any{
"search_article": map[string]any{
"cliName": "search",
"group": "article",
"flags": map[string]any{},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"-h"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(-h) error = %v", err)
}
if !strings.Contains(out.String(), "Discovered MCP Services:") {
t.Fatalf("root short help output missing MCP summary:\n%s", out.String())
}
}
func TestNestedShortHelpDoesNotRequirePINOrLogin(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
response := map[string]any{
"metadata": map[string]any{"count": 1, "nextCursor": ""},
"servers": []any{
discoveryServerEntry("devdoc", "开放平台文档搜索", map[string]any{
"article": map[string]any{"description": "文档文章"},
}, map[string]any{
"search_article": map[string]any{
"cliName": "search",
"group": "article",
"flags": map[string]any{
"keyword": map[string]any{"alias": "keyword"},
},
},
}),
},
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(response)
}))
defer srv.Close()
SetDiscoveryBaseURL(srv.URL)
t.Cleanup(func() { SetDiscoveryBaseURL("") })
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"devdoc", "article", "search", "-h"})
if err := root.Execute(); err != nil {
t.Fatalf("Execute(devdoc article search -h) error = %v", err)
}
if !strings.Contains(out.String(), "devdoc/search") {
t.Fatalf("nested short help output missing command title:\n%s", out.String())
}
}
func TestPrintExecutionError_RawStderrError_writes_raw_JSON_to_stderr(t *testing.T) {
t.Parallel()
rawJSON := `{"success":false,"code":"PAT_LOW_RISK_NO_PERMISSION","data":{}}`
err := &patLikeError{raw: rawJSON}
root := NewRootCommand()
var stdout, stderr bytes.Buffer
writeErr := printExecutionError(root, &stdout, &stderr, err)
if writeErr != nil {
t.Fatalf("printExecutionError() error = %v", writeErr)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty for RawStderrError", stdout.String())
}
got := strings.TrimSpace(stderr.String())
if got != rawJSON {
t.Fatalf("stderr = %q, want raw JSON %q", got, rawJSON)
}
}
func TestPrintExecutionError_RawStderrError_exit_code_is_4(t *testing.T) {
t.Parallel()
err := &patLikeError{raw: `{"code":"PAT_MEDIUM_RISK_NO_PERMISSION"}`}
exitCode := apperrors.ExitCode(err)
if exitCode != 4 {
t.Fatalf("apperrors.ExitCode(patLikeError) = %d, want 4", exitCode)
}
}
func TestPrintExecutionError_RawStderrError_takes_precedence_over_JSON_mode(t *testing.T) {
t.Parallel()
rawJSON := `{"success":false,"code":"PAT_HIGH_RISK_NO_PERMISSION"}`
err := &patLikeError{raw: rawJSON}
root := NewRootCommand()
_ = root.PersistentFlags().Set("format", "json")
var stdout, stderr bytes.Buffer
writeErr := printExecutionError(root, &stdout, &stderr, err)
if writeErr != nil {
t.Fatalf("printExecutionError() error = %v", writeErr)
}
if stdout.Len() != 0 {
t.Fatalf("stdout = %q, want empty — RawStderrError should bypass JSON mode", stdout.String())
}
if !strings.Contains(stderr.String(), "PAT_HIGH_RISK_NO_PERMISSION") {
t.Fatalf("stderr = %q, want raw PAT JSON", stderr.String())
}
}
// simulateExecuteWithPanic mirrors the recovery pattern in Execute():
// named return + defer recover → exitCode = 5 on panic.
func simulateExecuteWithPanic(doPanic bool) (exitCode int) {
defer func() {
if r := recover(); r != nil {
exitCode = 5
}
}()
if doPanic {
panic("test panic")
}
return 0
}
func TestExecute_panic_recovery_returns_exit_5(t *testing.T) {
t.Parallel()
code := simulateExecuteWithPanic(true)
if code != 5 {
t.Fatalf("panic recovery exitCode = %d, want 5", code)
}
}
func TestExecute_no_panic_returns_0(t *testing.T) {
t.Parallel()
code := simulateExecuteWithPanic(false)
if code != 0 {
t.Fatalf("no-panic exitCode = %d, want 0", code)
}
}

Some files were not shown because too many files have changed in this diff Show More