Compare commits

...
10 Commits
Author SHA1 Message Date
修雨 bba94c0092 docs(changelog): cut 1.0.46 (#542) 2026-07-01 16:45:06 +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
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
修雨 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
81 changed files with 6681 additions and 1806 deletions
+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
+3
View File
@@ -31,6 +31,9 @@ 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 (ad-hoc sign darwin binaries from Linux)
run: |
set -eu
+40
View File
@@ -6,9 +6,49 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [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
+38 -22
View File
@@ -233,6 +233,22 @@ 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.
</details>
<details>
<summary><strong>Migrate auth between Linux sandboxes</strong></summary>
@@ -524,29 +540,29 @@ See [`docs/robot-quickstart.md`](./docs/robot-quickstart.md) for the full 4-step
## Key Services
| Service | Command | Commands | Subcommands | Description |
|---------|---------|:--------:|-------------|-------------|
| Contact | `contact` | 15 | `user` `dept` `label` `relation` | Search users by name / mobile / job-number, batch query, departments, labels & roles, person relations, roster profile & dismissions, current user |
| Chat / IM | `chat` (alias `im`) | 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | Messages (send / reply / list / list-all / by-sender / mentions / focused / unread / topic replies / search / advanced search / forward / cards / emoji & text-emotion reactions / recall / read & send status queries), group CRUD + member management (members add / remove / list / `add-bot`, member-role CRUD, invite URL, icon, settings, transfer-owner, set-admin, quit), bot-identity messaging (`send-by-bot` / `recall-by-bot` / `send-by-webhook`), conversation info, common-groups lookup, group/member/conversation mute, conversation set-top, conversation categories |
| Calendar | `calendar` | 23 | `event` `book` `acl` `attendee` `participant` `room` `busy` `attachment` | Events CRUD + suggested times + attachments, calendar books (get / search / primary via `--id primary`), access-control list, attendee management (wukong-aligned naming, `--calendar-id` aware), meeting room booking, free-busy query |
| Todo | `todo` | 16 | `task` `comment` | Create / list / update / done / get / delete tasks, plus task comments |
| Approval | `oa` | 15 | `approval` | Approve / reject / revoke / redirect tasks, pending / initiated / submitted / executed / cc instances, process forms, comments, 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` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | Create / submit reports, sent & received (inbox / outbox) lists, templates (get / list), statistics, single-entry get |
| AI Tables | `aitable` | 102 | `base` `table` `record` `field` `view` `section` `advperm` `workflow` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Full CRUD for Bases / datasheets / records / fields / views; node (section) management, advanced permission & roles, automation workflows; record upsert / share-url / history / primary-doc; view lock / duplicate / frozen-cols / row-height / fill-color-rule / card / timebar; charts & dashboards; import/export; attachments; forms; templates |
| Doc | `doc` | 29 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` `permission` `media` | Search / read / write docs, file & folder create, block-level editing, comments (list / create / reply / create-inline), permission management, media, upload / download |
| Drive | `drive` | 17 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` `copy` `move` `rename` `search` `permission` | DingTalk drive file ops: list / search / info / download, create folders, upload (one-shot or two-phase), copy / move / rename, permission management (list / mutation / remove), doc-transfer, delete |
| Minutes | `minutes` | 31 | `list` `get` `tag` `update` `mind-graph` `speaker` `permission` `hot-word` `record` `upload` `replace-text` | List AI meeting notes (mine / shared / all), tags (list / query by tag), details (info / summary / keywords / transcription / todos / batch), title/summary updates, mind map, speaker, member permission, hot-word, recording control, upload session |
| Mail | `mail` | 33 | `mailbox` `message` `send` `draft` `folder` `template` `contact` `tag` `thread` `attachment` `user` | List mailboxes, KQL search + folder-scoped message list, read & send messages, drafts, folder CRUD, message templates (CRUD), mail contacts (CRUD), tags, threads, attachments, address-book user search |
| Sheet | `sheet` | 60 | `range` `filter` `filter-view` `cond-format` (+ dimension, float-image, dropdown, csv, merge, find/replace, write-image, …) | Online spreadsheet (`contentType=ALIDOC`, `extension=axls`): worksheet CRUD, range read / write / copy / fill / sort / style, dimension ops, filters & filter views, conditional formatting, float images, dropdowns, CSV get/put, cell merge / unmerge, find / replace, image write |
| Wiki | `wiki` | 27 | `space` `member` `node` `doc` `file` | Knowledge base management: spaces (create / get / list / search / delete), members (add / list / update / remove), node tree (create / delete / list / search / move / copy / transfer), docs & files |
| DevDoc | `devdoc` | 2 | `article` `error` | Search the DingTalk Open Platform documentation and diagnose API errors |
| AI Search | `aisearch` | 3 | `person` | Enterprise people search by name / department / position / duty / supervisor / subordinate / phone / job-number (single command, multi-dimension filter) |
| Live | `live` | 1 | `stream` | DingTalk live streaming: list my lives |
| Raw API | `api` | 1 | — | Call any DingTalk OpenAPI directly (api / oapi dual-form), with automatic app-level token management |
| 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 |
> **466 commands across 18 products** (after the dws-wukong alignment in 1.0.43). 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.
+38 -22
View File
@@ -233,6 +233,22 @@ 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` 各查一遍,然后合并。写操作默认只在当前组织进行——跨组织写之前先确认目标组织。
</details>
<details>
<summary><strong>沙箱间迁移登录态(Linux)</strong></summary>
@@ -520,29 +536,29 @@ dws dev connect --channel auto --robot-client-id <id> --robot-client-secret <sec
## 核心服务
| 服务 | 命令 | 命令数 | 子命令 | 描述 |
|------|------|:------:|--------|------|
| 通讯录 | `contact` | 15 | `user` `dept` `label` `relation` | 按姓名 / 手机号 / 工号搜索、批量查询、部门树、角色标签、人员关系、花名册与离职、当前用户信息 |
| 群聊 | `chat`(别名 `im`)| 65 | `message` `group` `bot` `conversation-info` `search` `search-common` `list-top-conversations` `group-mute` `group-mute-member` `mute` `set-top` `list-categories` `list-conversations` | 消息(发送 / 回复 / 列表 / list-all / 按发送者 / @我 / 关注 / 未读 / 话题回复 / 搜索 / 高级搜索 / 转发 / 卡片 / 表情与文本表情反应 / 撤回 / 已读与发送状态查询)、群增删改 + 成员管理(成员增 / 删 / 查 / `add-bot`、成员角色增删改查、邀请链接、群图标、群设置、转让群主、设置管理员、退群)、机器人身份消息(`send-by-bot` / `recall-by-bot` / `send-by-webhook`)、会话信息查询、共同群聊、群/成员/会话免打扰、会话置顶、会话分类 |
| 日历 | `calendar` | 17 | `event` `room` `participant` `busy` | 日程 CRUD + 建议时间 + 附件、会议室预订、闲忙查询、参与者管理 |
| 待办 | `todo` | 16 | `task` `comment` | 创建、列表、修改、完成、详情、删除,以及任务评论 |
| 审批 | `oa` | 15 | `approval` | 同意 / 拒绝 / 撤销 / 转交、待我审批 / 我发起 / 已提交 / 已办 / 抄送、流程表单、评论、操作记录 |
| 考勤 | `attendance` | 4 | `record` `shift` `summary` `rules` | 打卡记录、排班查询、考勤摘要、考勤组规则 |
| DING | `ding` | 2 | `message` | 发送 / 撤回 DING 消息 |
| 日志 | `report` | 20 | `create` `submit` `list` `detail` `template` `stats` `inbox` `outbox` `entry` | 创建 / 提交日志、收发(收件箱 / 发件箱)列表、模版(获取 / 列表)、详情、统计、单条获取 |
| AI 表格 | `aitable` | 52 | `base` `table` `record` `field` `view` `dashboard` `chart` `import` `export` `attachment` `template` `form` | Base / 数据表 / 记录 / 字段 / 视图 全量 CRUD;图表 + 仪表盘(含分享配置);数据导入导出;附件(仅获取凭证的 `upload` + 一键上传 `upload-file`);数据表表单;模板 |
| 文档 | `doc` | 28 | `search` `list` `info` `read` `create` `update` `upload` `download` `copy` `move` `rename` `file` `folder` `block` `comment` | 搜索 / 读写文档、文件与文件夹创建、块级编辑、评论(list / create / reply / create-inline)、上传 / 下载 |
| 钉盘 | `drive` | 9 | `list` `list-spaces` `info` `download` `mkdir` `upload` `upload-info` `commit` `delete` | 钉盘文件操作:列出空间、文件列表 / 详情 / 下载、创建文件夹、一键 `upload`(三步合成)或两阶段 `upload-info` + `commit`、删除 |
| AI 听记 | `minutes` | 19 | `list` `get` `update` `mind-graph` `speaker` `hot-word` `upload` | 听记列表(我创建 / 共享给我)、详情(info / summary / keywords / transcription / todos / batch)、标题/摘要更新、思维导图、发言人替换、热词、上传会话 |
| 邮箱 | `mail` | 18 | `mailbox` `message` `draft` `folder` `tag` `thread` `attachment` `user` | 邮箱地址列表、KQL 邮件搜索、读取与发送邮件、草稿、文件夹、标签、会话、附件、通讯录用户搜索 |
| 在线电子表格 | `sheet` | 23 | `range` `filter-view`(顶层:`create` `new` `list` `info` `read` `get` `update` `find` `replace` `append` `merge-cells` `unmerge-cells` `add-dimension` `insert-dimension` `delete-dimension` `move-dimension` `update-dimension` `write-image`) | 在线电子表格(`contentType=ALIDOC`、`extension=axls`):工作表 CRUD、区域读写/追加、行列操作、合并/取消合并、查找替换、命名筛选视图 + 表级筛选、写入图片 |
| 知识库 | `wiki` | 21 | `space` `member` `node` `doc` `file` | 知识库管理:空间(`create` / `get` / `list` / `search`)、成员(`add` / `list` / `update`)、节点树、文档与文件 |
| 开发者文档 | `devdoc` | 2 | `article` `error` | 搜索钉钉开放平台文档并排查 API 调用错误 |
| AI 搜问 | `aisearch` | 3 | `person` | 企业人员搜索:按姓名 / 部门 / 职位 / 职责 / 上级 / 下级 / 手机号 / 工号 多维度过滤(单命令) |
| 直播 | `live` | 1 | `stream` | 钉钉直播:查看我的直播列表 |
| 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 |
> **18 个产品,331 条命令。** 完整命令清单(带描述与使用场景):[`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` 产品。
+4 -4
View File
@@ -8,7 +8,7 @@ warehouse. This page is the integration contract.
| Header | Meaning | Granularity |
|--------|---------|-------------|
| `x-dingtalk-dws-agent-code` | which agent host (claudecode / codex / qoder / cursor / custom …) | channel |
| `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) | — |
@@ -26,7 +26,7 @@ clients send no `agent_code` / instance id — treat their absence as
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 — `custom`:** unknown host. Never guessed.
5. **T4 — unresolved:** unknown host sends no agent_code. Never guessed.
## Declaring your agent (recommended — the only fully-general path)
@@ -55,8 +55,8 @@ MCP server config example (JSON-style hosts):
`claudecode`, `codex`, `cursor`, `vscode`, `qoder`, `windsurf`, `trae`,
`workbuddy`, `openclaw`, `hermes`, `codebuddy`, `comate`, `lingma`, `gemini`,
`aider`, `opencode`, `goose`, `crush`, `kimi`, `amazonq`, `continue`, …
Use a stable lowercase slug; unknown values are kept as-is (lowercased,
spaces stripped), so a new agent name flows through cleanly.
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
+3 -3
View File
@@ -4,10 +4,13 @@ go 1.25.8
require (
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/itchyny/gojq v0.12.18
github.com/muesli/termenv v0.16.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
@@ -21,9 +24,7 @@ require (
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/bubbletea v1.3.6 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // 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
@@ -44,7 +45,6 @@ require (
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/muesli/termenv v0.16.0 // 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
+277 -48
View File
@@ -39,11 +39,12 @@ import (
)
type authLoginConfig struct {
Token string
Force bool
Device bool
Recommend bool
Yes bool
Token string
Force bool
Device bool
Recommend bool
Yes bool
TargetCorpID string
}
type authLoginGuideAction string
@@ -109,10 +110,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
否则 OAuth 回调会跳到本机不可达的 127.0.0.1 链接,授权完成后无法回写 token。
示例:
dws auth login # 本机登录后选择推荐/全部权限与授权业务域
dws auth login # 本机登录并新增/刷新一个组织 profile
dws auth login --profile <corpId> # 指定本次授权目标组织,不持久切换当前组织
dws auth login --recommend # 无交互批量授权服务端推荐权限
dws auth login --device # SSH 远程 / 无头环境登录 (设备流)
dws auth login --force # 强制重新登录 (忽略缓存 token)
dws auth login --force # 兼容保留;login 默认已忽略缓存并进入授权流程
dws auth login --token xxx # 使用指定 token`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
@@ -154,8 +156,9 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
provider := authpkg.NewOAuthProvider(configDir, nil)
provider.Output = cmd.ErrOrStderr()
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
provider.TargetCorpID = cfg.TargetCorpID
configureOAuthProviderCompatibility(provider, configDir)
tokenData, err = provider.Login(loginCtx, cfg.Force)
tokenData, err = provider.Login(loginCtx, authLoginForcesAuthorization(cfg))
if err != nil {
return apperrors.NewAuth(fmt.Sprintf("dingtalk login failed: %v", err))
}
@@ -163,6 +166,11 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
ResetRuntimeTokenCache()
clearCompatCache()
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" {
_ = enrichAuthLoginProfileFromContact(cmd.Context(), configDir, patCaller, tokenData)
ResetRuntimeTokenCache()
clearCompatCache()
}
w := cmd.OutOrStdout()
runPostLoginAuthorization := func() error {
@@ -217,7 +225,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
if err := runPostLoginAuthorization(); err != nil {
return err
}
return writeAuthLoginJSON(w, tokenData, cfg.Force)
return writeAuthLoginJSON(w, tokenData, authLoginForcesAuthorization(cfg))
}
// Default table output
@@ -225,7 +233,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
return err
}
fmt.Fprintln(w)
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !cfg.Force {
if !cfg.Device && tokenData != nil && tokenData.IsAccessTokenValid() && !authLoginForcesAuthorization(cfg) {
fmt.Fprintln(w, authLoginStatusLine("Token 有效,无需重新登录"))
} else {
fmt.Fprintln(w, authLoginStatusLine("登录成功!"))
@@ -250,7 +258,7 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
}
cmd.Flags().String("token", "", "Access token")
cmd.Flags().Bool("device", false, "Use device authorization flow")
cmd.Flags().Bool("force", false, "Force interactive login (ignore cached token)")
cmd.Flags().Bool("force", false, "兼容保留;login 默认已忽略缓存并进入授权流程")
cmd.Flags().Bool("recommend", false, "登录成功后无交互批量授权服务端推荐权限")
// Hidden compatibility flags
cmd.Flags().String("redirect-url", "", "Loopback redirect URL")
@@ -373,58 +381,67 @@ func selectLoginRecommendScopeMode() (pat.LoginRecommendScopeMode, error) {
}
func newAuthLogoutCommand() *cobra.Command {
return &cobra.Command{
Use: "logout",
Short: "清除认证信息",
cmd := &cobra.Command{
Use: "logout",
Short: "清除认证信息(默认退出所有组织)",
Long: `清除本机钉钉登录态。
默认退出所有已登录组织 profile;指定 --profile 时只退出该组织,不影响其他组织。`,
Example: ` dws auth logout
dws auth logout --profile <corpId>
dws auth logout --profile "钉钉"`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
profileSelector, err := cmd.Flags().GetString("profile")
if err != nil {
return apperrors.NewInternal("failed to read --profile")
}
revokeCtx, cancel := context.WithTimeout(cmd.Context(), 15*time.Second)
defer cancel()
_ = authpkg.RevokeTokenRemote(revokeCtx)
// Load token data to get associated clientId before deletion
var storedClientID string
if tokenData, err := authpkg.LoadTokenData(configDir); err == nil && tokenData != nil {
storedClientID = tokenData.ClientID
if strings.TrimSpace(profileSelector) != "" {
if err := logoutOneProfile(cmd, revokeCtx, configDir, profileSelector); err != nil {
return err
}
} else {
if err := logoutAllProfiles(cmd, revokeCtx, configDir); err != nil {
return err
}
}
if err := authpkg.DeleteTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
// Clean up associated client secret and app token from keychain
if storedClientID != "" {
_ = authpkg.DeleteClientSecret(storedClientID)
_ = authpkg.DeleteAppTokenData(storedClientID)
}
// Also try cleaning app token using appKey from app config
if appKey, _ := authpkg.ResolveAppCredentials(configDir); appKey != "" && appKey != storedClientID {
_ = authpkg.DeleteAppTokenData(appKey)
}
// Clean up app credentials (app.json + keychain secret)
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = os.Remove(filepath.Join(configDir, "token.json"))
ResetRuntimeTokenCache()
clearCompatCache()
w := cmd.OutOrStdout()
fmt.Fprintln(w, "[OK] 已清除所有认证信息")
fmt.Fprintln(w, "[OK] 已清除认证信息")
if !edition.Get().IsEmbedded {
fmt.Fprintln(w, "请运行 dws auth login --recommend 重新登录")
}
return nil
},
}
cmd.Flags().String("profile", "", "指定要退出的 profile 名或 corpId")
return cmd
}
func newAuthStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "查看认证状态",
cmd := &cobra.Command{
Use: "status",
Short: "查看认证状态",
Long: `查看当前或指定组织 profile 的认证状态。
指定 --profile 时只读取并刷新被选中的 token slot,不会修改 currentProfile。`,
Example: ` dws auth status
dws auth status --profile <corpId>
dws auth status --profile "钉钉"
dws auth status --profile <corpId> --format json`,
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
profileSelector, err := cmd.Flags().GetString("profile")
if err != nil {
return apperrors.NewInternal("failed to read --profile")
}
restoreProfile := pushRuntimeProfile(profileSelector)
defer restoreProfile()
authenticated := false
refreshed := false
@@ -444,6 +461,8 @@ func newAuthStatusCommand() *cobra.Command {
}
} else if edition.Get().AutoPurgeToken {
_ = authpkg.DeleteTokenData(configDir)
} else if tokenData != nil {
_ = authpkg.MarkProfileStatus(configDir, tokenData.CorpID, authpkg.ProfileStatusExpired)
}
}
if authStatusAuthenticated(tokenData) {
@@ -467,6 +486,12 @@ func newAuthStatusCommand() *cobra.Command {
fmt.Fprintf(w, "%-16s%s\n", "状态:", "已登录 ✅")
}
if tokenData != nil {
if tokenData.CorpName != "" {
fmt.Fprintf(w, "%-16s%s\n", "企业:", tokenData.CorpName)
}
if tokenData.CorpID != "" {
fmt.Fprintf(w, "%-16s%s\n", "企业 ID:", tokenData.CorpID)
}
if tokenData.IsRefreshTokenValid() {
fmt.Fprintf(w, "%-16s%s\n", "Refresh Token:", "有效 ✅")
} else {
@@ -485,6 +510,74 @@ func newAuthStatusCommand() *cobra.Command {
return nil
},
}
cmd.Flags().String("profile", "", "指定要查看的 profile 名或 corpId")
return cmd
}
func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector string) error {
if _, err := authpkg.ResolveProfile(configDir, selector); err != nil {
return apperrors.NewValidation(err.Error())
}
restoreProfile := pushRuntimeProfile(selector)
defer restoreProfile()
_ = authpkg.RevokeTokenRemote(ctx)
if err := authpkg.DeleteTokenDataForProfile(configDir, selector); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
return nil
}
func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string) error {
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 {
_ = authpkg.RevokeTokenRemote(ctx)
} else {
for _, profile := range cfg.Profiles {
restoreProfile := pushRuntimeProfile(profile.CorpID)
_ = authpkg.RevokeTokenRemote(ctx)
restoreProfile()
}
}
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to clear token data: %v", err))
}
return nil
}
func pushRuntimeProfile(selector string) func() {
selector = strings.TrimSpace(selector)
if selector == "" {
return func() {}
}
previous := authpkg.RuntimeProfile()
authpkg.SetRuntimeProfile(selector)
return func() {
authpkg.SetRuntimeProfile(previous)
}
}
func cleanupAuthConfigIfNoProfiles(configDir string) {
cfg, err := authpkg.LoadProfiles(configDir)
if err == nil && len(cfg.Profiles) > 0 {
return
}
if authpkg.TokenDataExistsKeychain() {
return
}
appKey, _ := authpkg.ResolveAppCredentials(configDir)
if appKey != "" {
_ = authpkg.DeleteAppTokenData(appKey)
}
_ = authpkg.DeleteAppConfig(configDir)
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = authpkg.DeleteTokenMarker(configDir)
}
func newAuthExportCommand() *cobra.Command {
@@ -683,11 +776,12 @@ func newAuthResetCommand() *cobra.Command {
DisableAutoGenTag: true,
RunE: func(cmd *cobra.Command, args []string) error {
configDir := defaultConfigDir()
if err := authpkg.DeleteTokenData(configDir); err != nil {
if err := authpkg.DeleteAllTokenData(configDir); err != nil {
return apperrors.NewInternal(fmt.Sprintf("failed to reset token data: %v", err))
}
_ = os.Remove(filepath.Join(configDir, "mcp_url"))
_ = os.Remove(filepath.Join(configDir, "token"))
_ = authpkg.DeleteAppConfig(configDir)
ResetRuntimeTokenCache()
clearCompatCache()
w := cmd.OutOrStdout()
@@ -958,18 +1052,153 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
return authLoginConfig{}, apperrors.NewInternal("failed to read --recommend")
}
yes := false
profileSelector := ""
if cmd.Root() != nil {
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
}
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
if err != nil {
return authLoginConfig{}, err
}
return authLoginConfig{
Token: strings.TrimSpace(token),
Force: force,
Device: device,
Recommend: recommend,
Yes: yes,
Token: strings.TrimSpace(token),
Force: force,
Device: device,
Recommend: recommend,
Yes: yes,
TargetCorpID: targetCorpID,
}, nil
}
func authLoginForcesAuthorization(_ authLoginConfig) bool {
return true
}
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
selector = strings.TrimSpace(selector)
if selector == "" {
return "", nil
}
if profile, err := authpkg.ResolveProfile(configDir, selector); err == nil && profile != nil {
return strings.TrimSpace(profile.CorpID), nil
}
if strings.HasPrefix(selector, "ding") {
return selector, nil
}
return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
}
type contactProfileIdentity struct {
CorpID string
CorpName string
UserID string
UserName string
}
func enrichAuthLoginProfileFromContact(ctx context.Context, configDir string, caller edition.ToolCaller, data *authpkg.TokenData) error {
if caller == nil || data == nil {
return nil
}
corpID := strings.TrimSpace(data.CorpID)
if corpID == "" {
return nil
}
if strings.TrimSpace(data.CorpName) != "" && strings.TrimSpace(data.UserID) != "" && strings.TrimSpace(data.UserName) != "" {
return nil
}
restoreProfile := pushRuntimeProfile(corpID)
defer restoreProfile()
ResetRuntimeTokenCache()
result, err := caller.CallTool(ctx, "contact", "get_current_user_profile", map[string]any{
"profile": corpID,
})
if err != nil {
return err
}
identity, ok := contactProfileIdentityFromToolResult(result)
if !ok {
return nil
}
if identity.CorpID != "" && identity.CorpID != corpID {
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
}
updated := *data
if identity.CorpName != "" {
updated.CorpName = identity.CorpName
}
if identity.UserID != "" {
updated.UserID = identity.UserID
}
if identity.UserName != "" {
updated.UserName = identity.UserName
}
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
return nil
}
if err := authpkg.SaveTokenData(configDir, &updated); err != nil {
return err
}
*data = updated
return nil
}
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
if result == nil {
return contactProfileIdentity{}, false
}
for _, block := range result.Content {
if strings.TrimSpace(block.Text) == "" {
continue
}
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
return identity, true
}
}
return contactProfileIdentity{}, false
}
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
var payload struct {
Result []struct {
OrgEmployeeModel struct {
CorpID string `json:"corpId"`
OrgName string `json:"orgName"`
UserID string `json:"userId"`
UserIDLower string `json:"userid"`
OrgUserName string `json:"orgUserName"`
Name string `json:"name"`
} `json:"orgEmployeeModel"`
} `json:"result"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return contactProfileIdentity{}, false
}
if len(payload.Result) == 0 {
return contactProfileIdentity{}, false
}
org := payload.Result[0].OrgEmployeeModel
identity := contactProfileIdentity{
CorpID: strings.TrimSpace(org.CorpID),
CorpName: strings.TrimSpace(org.OrgName),
UserID: firstNonEmptyString(org.UserID, org.UserIDLower),
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
}
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func authStatusAuthenticated(data *authpkg.TokenData) bool {
if data == nil {
return false
+249
View File
@@ -184,6 +184,161 @@ func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
}
}
func TestAuthStatusTableIncludesCorpName(t *testing.T) {
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_primary"))
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"--format", "table", "auth", "status"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --format table error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"企业:", "corp_primary org", "企业 ID:", "corp_primary"} {
if !bytes.Contains(out.Bytes(), []byte(want)) {
t.Fatalf("auth status table missing %q in output:\n%s", want, out.String())
}
}
}
func TestAuthStatusProfileOverrideDoesNotSwitchCurrentProfile(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", "auth", "status", "--profile", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth status --profile error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"corp_primary org", "corp_primary"} {
if !bytes.Contains(out.Bytes(), []byte(want)) {
t.Fatalf("auth status --profile output missing %q:\n%s", want, out.String())
}
}
if bytes.Contains(out.Bytes(), []byte("corp_secondary org")) {
t.Fatalf("auth status --profile should render selected profile, got:\n%s", 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 unchanged corp_secondary", cfg.CurrentProfile)
}
}
func TestAuthLogoutDefaultDeletesAllProfilesAndPreservesAppConfig(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
if err := authpkg.SaveAppConfig(configDir, &authpkg.AppConfig{
ClientID: "client-app",
ClientSecret: authpkg.PlainSecret("secret-app"),
}); err != nil {
t.Fatalf("SaveAppConfig() error = %v", err)
}
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("remote revoke disabled in unit test")
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "logout"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth logout error = %v\noutput:\n%s", err, out.String())
}
for _, want := range []string{"[OK] 已清除认证信息", "重新登录"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("auth logout output missing %q:\n%s", want, out.String())
}
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
t.Fatalf("profiles after logout = %#v, want empty", cfg)
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
t.Fatal("primary profile token should be deleted")
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
t.Fatal("secondary profile token should be deleted")
}
if authpkg.TokenDataExistsKeychain() {
t.Fatal("legacy auth-token mirror should be deleted")
}
appConfig, err := authpkg.LoadAppConfig(configDir)
if err != nil {
t.Fatalf("LoadAppConfig() error = %v", err)
}
if appConfig == nil || appConfig.ClientID != "client-app" {
t.Fatalf("app config after logout = %#v, want preserved client-app", appConfig)
}
}
func TestAuthLogoutProfileDeletesOnlySelectedProfile(t *testing.T) {
configDir := setupAuthLogoutProfiles(t,
authLogoutTestToken("corp_primary"),
authLogoutTestToken("corp_secondary"),
)
originalTransport := http.DefaultTransport
t.Cleanup(func() {
http.DefaultTransport = originalTransport
})
http.DefaultTransport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("remote revoke disabled in unit test")
})
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs([]string{"auth", "logout", "--profile", "corp_primary"})
if err := cmd.Execute(); err != nil {
t.Fatalf("auth logout --profile corp_primary error = %v\noutput:\n%s", err, out.String())
}
cfg, err := authpkg.LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_secondary" || cfg.CurrentProfile != "corp_secondary" {
t.Fatalf("profiles pointers = primary %q current %q, want corp_secondary/corp_secondary", cfg.PrimaryProfile, cfg.CurrentProfile)
}
if len(cfg.Profiles) != 1 || cfg.Profiles[0].CorpID != "corp_secondary" {
t.Fatalf("profiles = %#v, want only corp_secondary retained", cfg.Profiles)
}
if authpkg.TokenDataExistsKeychainForCorpID("corp_primary") {
t.Fatal("selected primary profile token should be deleted")
}
if !authpkg.TokenDataExistsKeychainForCorpID("corp_secondary") {
t.Fatal("unselected secondary profile token should be retained")
}
loaded, err := authpkg.LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.CorpID != "corp_secondary" || loaded.AccessToken != "access-corp_secondary" {
t.Fatalf("default token = (%q, %q), want retained secondary token", loaded.CorpID, loaded.AccessToken)
}
}
func TestAuthLoginPostLoginTUIModeRespectsRecommendAndFormat(t *testing.T) {
newRoot := func(t *testing.T) *cobra.Command {
t.Helper()
@@ -297,6 +452,15 @@ func TestResolveAuthLoginConfigReadsInheritedYes(t *testing.T) {
}
}
func TestAuthLoginForcesAuthorizationByDefault(t *testing.T) {
if !authLoginForcesAuthorization(authLoginConfig{}) {
t.Fatal("auth login should force authorization by default so each login can add an organization profile")
}
if !authLoginForcesAuthorization(authLoginConfig{Force: false}) {
t.Fatal("Force=false should still force authorization")
}
}
func TestAuthLoginRecommendSkipsPostLoginTUI(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
@@ -578,6 +742,53 @@ func TestAuthLoginDefaultTUIRunsAfterLoginTokenSaved(t *testing.T) {
}
}
func TestEnrichAuthLoginProfileFromContactPersistsCorpName(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
configDir := t.TempDir()
t.Setenv("DWS_CONFIG_DIR", configDir)
token := &authpkg.TokenData{
AccessToken: "access-token",
RefreshToken: "refresh-token",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "ding32fff839a3e0105d",
ClientID: "client-id",
Source: "mcp",
}
if err := authpkg.SaveTokenData(configDir, token); err != nil {
t.Fatalf("SaveTokenData() error = %v", err)
}
fake := &authLoginRecommendSequenceCaller{responses: []string{
`{"success":true,"result":[{"orgEmployeeModel":{"corpId":"ding32fff839a3e0105d","orgName":"钉钉(中国)信息技术有限公司","userId":"011352590165863362195","orgUserName":"玄玦(主用钉)"}}]}`,
}}
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, fake, token); err != nil {
t.Fatalf("enrichAuthLoginProfileFromContact() error = %v", err)
}
if token.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("token corpName = %q, want 钉钉(中国)信息技术有限公司", token.CorpName)
}
if token.UserID != "011352590165863362195" || token.UserName != "玄玦(主用钉)" {
t.Fatalf("token user identity = (%q, %q), want contact result", token.UserID, token.UserName)
}
loaded, err := authpkg.LoadTokenDataForProfile(configDir, "ding32fff839a3e0105d")
if err != nil {
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
}
if loaded.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("persisted corpName = %q, want 钉钉(中国)信息技术有限公司", loaded.CorpName)
}
if len(fake.tools) != 1 || fake.tools[0] != "get_current_user_profile" {
t.Fatalf("tool calls = %v, want get_current_user_profile", fake.tools)
}
if got := fake.args[0]["profile"]; got != "ding32fff839a3e0105d" {
t.Fatalf("contact profile arg = %#v, want ding32fff839a3e0105d", got)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -642,3 +853,41 @@ func stringSliceArgEqual(got any, want []string) bool {
return false
}
}
func setupAuthLogoutProfiles(t *testing.T, tokens ...*authpkg.TokenData) string {
t.Helper()
root := t.TempDir()
configDir := filepath.Join(root, "config")
t.Setenv(keychain.DisableKeychainEnv, "1")
t.Setenv(keychain.StorageDirEnv, filepath.Join(root, "keychain"))
t.Setenv("DWS_CONFIG_DIR", configDir)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
clearCompatCache()
t.Cleanup(func() {
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
clearCompatCache()
})
for _, token := range tokens {
if err := authpkg.SaveTokenData(configDir, token); err != nil {
t.Fatalf("SaveTokenData(%s) error = %v", token.CorpID, err)
}
}
return configDir
}
func authLogoutTestToken(corpID string) *authpkg.TokenData {
return &authpkg.TokenData{
AccessToken: "access-" + corpID,
RefreshToken: "refresh-" + corpID,
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: corpID,
CorpName: corpID + " org",
UserID: "user-" + corpID,
UserName: "User " + corpID,
ClientID: "client-" + corpID,
}
}
+2
View File
@@ -29,6 +29,7 @@ type GlobalFlags struct {
JQ string
Mock bool
Output string
Profile string
Timeout int
Token string
Verbose bool
@@ -46,6 +47,7 @@ func bindPersistentFlags(cmd *cobra.Command, flags *GlobalFlags) {
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")
+76 -1
View File
@@ -163,11 +163,16 @@ func TestRootHelpUsesMCPOnlySummary(t *testing.T) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理", "Flags:"} {
for _, unwanted := range []string{"快速开始:", "更多信息:", "auth 认证管理"} {
if strings.Contains(got, unwanted) {
t.Fatalf("root help unexpectedly contains %q:\n%s", unwanted, got)
}
}
for _, want := range []string{"Global Flags:", "--profile"} {
if !strings.Contains(got, want) {
t.Fatalf("root help missing %q:\n%s", want, got)
}
}
}
func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
@@ -217,6 +222,60 @@ func TestRootHelpCustomizationDoesNotAffectSubcommandHelp(t *testing.T) {
}
}
func TestProfileHelpDocumentsMultiProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "profile", "switch", "--help")
for _, want := range []string{
"切换默认组织 profile",
"需要只影响单次业务命令时,请使用全局 --profile",
"dws profile switch --corpId <corpId>",
"dws --profile <corpId> contact user get-self",
"--corpId string",
"--name string",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile switch help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "profile", "list", "--help")
for _, want := range []string{
"列出本机已登录的所有组织 profile",
"dws profile list --format json",
} {
if !strings.Contains(got, want) {
t.Fatalf("profile list help missing %q:\n%s", want, got)
}
}
}
func TestAuthHelpDocumentsProfileUsage(t *testing.T) {
got := executeHelpForTest(t, "auth", "login", "--help")
if !strings.Contains(got, "dws auth login --profile <corpId>") {
t.Fatalf("auth login help missing --profile example:\n%s", got)
}
got = executeHelpForTest(t, "auth", "status", "--help")
for _, want := range []string{
"查看当前或指定组织 profile 的认证状态",
"只读取并刷新被选中的 token slot",
"dws auth status --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth status help missing %q:\n%s", want, got)
}
}
got = executeHelpForTest(t, "auth", "logout", "--help")
for _, want := range []string{
"默认退出所有已登录组织 profile",
"dws auth logout --profile <corpId>",
} {
if !strings.Contains(got, want) {
t.Fatalf("auth logout help missing %q:\n%s", want, got)
}
}
}
func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
root := NewRootCommand()
if cmd := lookupCommand(root, "upgrade"); cmd == nil {
@@ -224,6 +283,22 @@ func TestRootCommandRegistersUpgradeCommand(t *testing.T) {
}
}
func executeHelpForTest(t *testing.T, args ...string) string {
t.Helper()
t.Setenv(cli.CatalogFixtureEnv, "")
t.Setenv(cli.CacheDirEnv, t.TempDir())
root := NewRootCommand()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
return out.String()
}
func discoveryServerEntry(command, description string, groups, toolOverrides map[string]any) map[string]any {
cliMeta := map[string]any{
"id": command,
+45 -1
View File
@@ -351,15 +351,59 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
existingTools := loadCachedToolNames(store, servers)
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID, existingTools)
RecordTiming(ctx, "build_commands", time.Since(buildStart))
return cmds
}
// loadCachedToolNames reads the live tools/list snapshot from disk cache for
// each server and returns a map from CLI server ID (slug) → set of tool names
// the server actually exposes. This is the existence oracle BuildDynamicCommands
// uses to hide phantom override leaves (commands whose backing MCP tool is not
// deployed) from `--help`.
//
// Source note: this reads the `tools/` partition (populated by `dws cache
// refresh` / discovery, keyed by server.Key), NOT the `detail/` partition used
// by loadCachedDetailsFast — the latter is frequently empty even after a
// refresh, so it is unusable as an existence signal.
//
// Keyed by cli.ID so serverOverride routing (e.g. contact → hrmregister)
// resolves against the target server's tool set. A server with no cached tools
// is simply absent from the map; the build guard treats "absent / empty" as
// "unknown" and keeps the command, so a cold cache never blanks the tree.
func loadCachedToolNames(store *cache.Store, servers []market.ServerDescriptor) map[string]map[string]struct{} {
result := make(map[string]map[string]struct{})
if store == nil {
return result
}
partition := editionPartition()
for _, server := range servers {
slug := strings.TrimSpace(server.CLI.ID)
if slug == "" || strings.TrimSpace(server.Key) == "" {
continue
}
snap, _, err := store.LoadTools(partition, server.Key)
if err != nil || len(snap.Tools) == 0 {
continue
}
names := make(map[string]struct{}, len(snap.Tools))
for _, t := range snap.Tools {
if n := strings.TrimSpace(t.Name); n != "" {
names[n] = struct{}{}
}
}
if len(names) > 0 {
result[slug] = names
}
}
return result
}
// 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 {
+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)
}
}
+4 -1
View File
@@ -148,8 +148,11 @@ func registerStdioServerFromOverlay(
}
}
// nil existingTools: this overlay is built from the plugin's own live tool
// list (detailsByID is derived from it), so there are no phantom leaves to
// guard against here.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered from overlay",
"plugin", p.Manifest.Name, "server", sc.Key,
+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)
}
}
@@ -0,0 +1,158 @@
package app
import (
"bytes"
"context"
"sync"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"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/spf13/cobra"
)
func TestProductCommandsAcceptGlobalProfileFlag(t *testing.T) {
const selectedProfile = "corp_profile_matrix"
products := []struct {
name string
path []string
tool string
}{
{name: "aitable", path: []string{"aitable", "profile-test", "probe"}, tool: "aitable_profile_probe"},
{name: "attendance", path: []string{"attendance", "profile-test", "probe"}, tool: "attendance_profile_probe"},
{name: "calendar", path: []string{"calendar", "profile-test", "probe"}, tool: "calendar_profile_probe"},
{name: "contact", path: []string{"contact", "profile-test", "probe"}, tool: "contact_profile_probe"},
{name: "devdoc", path: []string{"devdoc", "profile-test", "probe"}, tool: "devdoc_profile_probe"},
{name: "ding", path: []string{"ding", "profile-test", "probe"}, tool: "ding_profile_probe"},
{name: "report", path: []string{"report", "profile-test", "probe"}, tool: "report_profile_probe"},
{name: "todo", path: []string{"todo", "profile-test", "probe"}, tool: "todo_profile_probe"},
}
descriptors := make([]market.ServerDescriptor, 0, len(products))
for _, product := range products {
descriptors = append(descriptors, profileFlagProductDescriptor(product.name, product.tool))
}
capture := &profileFlagRunner{}
oldLoadDynamicCommands := loadDynamicCommandsFn
loadDynamicCommandsFn = func(_ context.Context, _ executor.Runner) []*cobra.Command {
SetDynamicServers(descriptors)
return compat.BuildDynamicCommands(descriptors, capture, nil, nil)
}
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
t.Cleanup(func() {
loadDynamicCommandsFn = oldLoadDynamicCommands
SetDynamicServers(nil)
authpkg.SetRuntimeProfile("")
ResetRuntimeTokenCache()
})
for _, product := range products {
t.Run(product.name, func(t *testing.T) {
capture.reset()
authpkg.SetRuntimeProfile("")
cmd := NewRootCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
args := append([]string{"-f", "json"}, product.path...)
args = append(args, "--profile", selectedProfile)
cmd.SetArgs(args)
// Arrange / Act: execute a product command with root --profile after the leaf.
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute(%v) error = %v\noutput:\n%s", args, err, out.String())
}
// Assert: the product tool runs under the selected profile without leaking it as a business arg.
call := capture.last()
if call == nil {
t.Fatal("expected product command to invoke runner")
}
if call.product != product.name {
t.Fatalf("canonical product = %q, want %q", call.product, product.name)
}
if call.tool != product.tool {
t.Fatalf("tool = %q, want %q", call.tool, product.tool)
}
if call.profile != selectedProfile {
t.Fatalf("runtime profile at execution = %q, want %q", call.profile, selectedProfile)
}
if _, ok := call.params["profile"]; ok {
t.Fatalf("--profile leaked into business params: %#v", call.params)
}
})
}
}
func profileFlagProductDescriptor(product, tool string) market.ServerDescriptor {
return market.ServerDescriptor{
Key: product,
DisplayName: product,
Endpoint: "https://example.invalid/" + product,
CLI: market.CLIOverlay{
ID: product,
Command: product,
Groups: map[string]market.CLIGroupDef{
"profile-test": {Description: "profile-test"},
},
ToolOverrides: map[string]market.CLIToolOverride{
tool: {
CLIName: "probe",
Group: "profile-test",
Description: tool,
RejectPositional: true,
},
},
},
}
}
type profileFlagCall struct {
product string
tool string
profile string
params map[string]any
}
type profileFlagRunner struct {
mu sync.Mutex
calls []profileFlagCall
}
func (r *profileFlagRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
r.mu.Lock()
defer r.mu.Unlock()
params := make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
params[key] = value
}
r.calls = append(r.calls, profileFlagCall{
product: invocation.CanonicalProduct,
tool: invocation.Tool,
profile: authpkg.RuntimeProfile(),
params: params,
})
return executor.Result{Invocation: invocation}, nil
}
func (r *profileFlagRunner) reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = nil
}
func (r *profileFlagRunner) last() *profileFlagCall {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.calls) == 0 {
return nil
}
call := r.calls[len(r.calls)-1]
return &call
}
+92 -3
View File
@@ -67,6 +67,9 @@ func Execute() (exitCode int) {
}
}()
restoreArgs := normalizeProcessProfileArgs()
defer restoreArgs()
timing := NewTimingCollector()
defer func() {
StopAllStdioClients() // Ensure child processes are terminated on exit
@@ -298,6 +301,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
rootCtx = context.Background()
}
flags := &GlobalFlags{}
authpkg.SetRuntimeProfile(preparseProfileFlag(os.Args[1:]))
loader := cli.EnvironmentLoader{
LookupEnv: os.LookupEnv,
CatalogBaseURLOverride: DiscoveryBaseURL(),
@@ -321,6 +325,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
if flags.ClientID != "" {
authpkg.SetClientID(flags.ClientID)
@@ -358,6 +363,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
utilityCommands := []*cobra.Command{
newAuthCommand(patCaller),
newProfileCommand(),
newAPICommand(flags),
newSkillCommand(),
newCacheCommand(),
@@ -404,6 +410,85 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
return root
}
func preparseProfileFlag(args []string) string {
args, _ = normalizeProfileFlagArgs(args)
for i := 0; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
switch {
case arg == "--profile" && i+1 < len(args):
return strings.TrimSpace(args[i+1])
case strings.HasPrefix(arg, "--profile="):
return strings.TrimSpace(strings.TrimPrefix(arg, "--profile="))
}
}
return ""
}
func normalizeProcessProfileArgs() func() {
original := append([]string(nil), os.Args...)
if len(os.Args) > 1 {
if normalized, changed := normalizeProfileFlagArgs(os.Args[1:]); changed {
os.Args = append([]string{os.Args[0]}, normalized...)
}
}
return func() {
os.Args = original
}
}
func normalizeProfileFlagArgs(args []string) ([]string, bool) {
if len(args) == 0 {
return args, false
}
out := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
trimmed := strings.TrimSpace(arg)
switch {
case trimmed == "--profile":
out = append(out, arg)
if i+1 >= len(args) {
continue
}
value, next := collectProfileFlagValue(args[i+1], args, i+2)
out = append(out, value)
i = next - 1
case strings.HasPrefix(trimmed, "--profile="):
value, next := collectProfileFlagValue(strings.TrimPrefix(trimmed, "--profile="), args, i+1)
out = append(out, "--profile="+value)
i = next - 1
default:
out = append(out, arg)
}
}
return out, argsChanged(args, out)
}
func collectProfileFlagValue(first string, args []string, next int) (string, int) {
parts := []string{strings.TrimSpace(first)}
for len(parts) > 0 && strings.HasSuffix(strings.TrimSpace(parts[len(parts)-1]), ",") && next < len(args) {
candidate := strings.TrimSpace(args[next])
if candidate == "" || strings.HasPrefix(candidate, "-") {
break
}
parts = append(parts, candidate)
next++
}
return strings.Join(parts, ""), next
}
func argsChanged(before, after []string) bool {
if len(before) != len(after) {
return true
}
for i := range before {
if before[i] != after[i] {
return true
}
}
return false
}
func newAuthCommand(patCaller edition.ToolCaller) *cobra.Command {
return buildAuthCommand(patCaller)
}
@@ -802,6 +887,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
"completion": true,
"skill": true,
"plugin": true,
"profile": true,
"version": true,
"help": true,
"recovery": true,
@@ -828,7 +914,7 @@ func hideNonDirectRuntimeCommands(root *cobra.Command) {
// by a malicious or misconfigured plugin.
var reservedCommands = map[string]bool{
"auth": true, "api": true, "login": true, "logout": true,
"plugin": true, "skill": true, "cache": true,
"plugin": true, "profile": true, "skill": true, "cache": true,
"config": true, "doctor": true, "completion": true,
"recovery": true, "upgrade": true, "version": true,
"schema": true, "mcp": true, "help": true,
@@ -1503,8 +1589,10 @@ func buildHTTPCommandsFromTools(srv market.ServerDescriptor, tools []transport.T
}
}
// nil existingTools: single-server overlay built from a live tool list, so
// no phantom-leaf guard is needed (see BuildDynamicCommands doc).
return compat.BuildDynamicCommands(
[]market.ServerDescriptor{srv}, runner, detailsByID)
[]market.ServerDescriptor{srv}, runner, detailsByID, nil)
}
// deriveToolCLIName converts an MCP tool name (e.g. "web_search" or
@@ -1691,8 +1779,9 @@ func buildStdioCommands(p *plugin.Plugin, sc plugin.StdioServerClient, tools []t
RegisterStdioClient(p.Manifest.Name+"/"+sc.Key, sc.Client)
detailsByID := toolsToDetails(tools, overlay.ID)
// nil existingTools: overlay built from this plugin's live tool list.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered",
"plugin", p.Manifest.Name, "server", sc.Key,
+1 -1
View File
@@ -263,7 +263,7 @@ func TestRootHelpDoesNotRequirePINOrLogin(t *testing.T) {
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"} {
for _, want := range []string{"Utility Commands:", "skill", "auth", "profile", "version", "Global Flags:", "--profile"} {
if !strings.Contains(out.String(), want) {
t.Fatalf("root help output missing %q:\n%s", want, out.String())
}
+49
View File
@@ -9,6 +9,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func configureRootHelp(root *cobra.Command) {
@@ -86,6 +87,7 @@ func renderRootHelp(root *cobra.Command) {
_ = tw.Flush()
_, _ = fmt.Fprintln(w)
}
renderRootGlobalFlags(root)
_, _ = fmt.Fprintf(w, "%s %s\n", tui.Key("Next"), `Use "dws <service> --help" for more information about a discovered MCP service or "dws <command> --help" for utility commands.`)
// Render root.Long after the command list so agents see the upgrade
@@ -99,6 +101,53 @@ func renderRootHelp(root *cobra.Command) {
}
}
func renderRootGlobalFlags(root *cobra.Command) {
if root == nil {
return
}
flags := visiblePersistentFlags(root)
if len(flags) == 0 {
return
}
w := root.OutOrStdout()
_, _ = fmt.Fprintln(w, tui.Section("Global Flags:"))
_, _ = fmt.Fprintln(w)
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
for _, flag := range flags {
_, _ = fmt.Fprintf(tw, " %s\t%s\n", formatRootFlag(flag), tui.Dim(strings.TrimSpace(flag.Usage)))
}
_ = tw.Flush()
_, _ = fmt.Fprintln(w)
}
func visiblePersistentFlags(root *cobra.Command) []*pflag.Flag {
if root == nil {
return nil
}
flags := make([]*pflag.Flag, 0)
root.PersistentFlags().VisitAll(func(flag *pflag.Flag) {
if flag == nil || flag.Hidden {
return
}
flags = append(flags, flag)
})
return flags
}
func formatRootFlag(flag *pflag.Flag) string {
if flag == nil {
return ""
}
name := "--" + flag.Name
if flag.Value != nil && flag.Value.Type() != "bool" {
name += " " + flag.Value.Type()
}
if flag.Shorthand == "" {
return " " + name
}
return "-" + flag.Shorthand + ", " + name
}
func commandShort(cmd *cobra.Command) string {
if cmd == nil {
return ""
+193 -27
View File
@@ -162,6 +162,18 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
// invocations within the same process free.
logHostOwnedPATDecisionOnce()
selections, multi, err := resolveMultiProfileSelections(defaultConfigDir(), authpkg.RuntimeProfile())
if err != nil {
return executor.Result{}, apperrors.NewValidation(err.Error())
}
if multi {
return r.runMultiProfile(ctx, invocation, selections)
}
return r.runSingle(ctx, invocation, true)
}
func (r *runtimeRunner) runSingle(ctx context.Context, invocation executor.Invocation, prefetchToken bool) (executor.Result, error) {
if r.loader == nil || r.transport == nil {
return r.fallback.Run(ctx, invocation)
}
@@ -179,7 +191,9 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
// Prefetch the Keychain token in the background. Keychain access costs
// ~70ms on macOS; starting it here lets the load overlap with endpoint
// resolution and catalog loading below.
go getCachedRuntimeToken(ctx)
if prefetchToken {
go getCachedRuntimeToken(ctx)
}
if shouldUseDirectRuntime(invocation) {
if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok {
@@ -239,6 +253,144 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation)
return r.executeInvocation(ctx, endpoint, invocation)
}
type multiProfileSelection struct {
Selector string
Profile authpkg.Profile
}
func resolveMultiProfileSelections(configDir, rawSelector string) ([]multiProfileSelection, bool, error) {
rawSelector = strings.TrimSpace(rawSelector)
if rawSelector == "" || !strings.Contains(rawSelector, ",") {
return nil, false, nil
}
if p, err := authpkg.ResolveProfile(configDir, rawSelector); err == nil && p != nil {
return nil, false, nil
}
parts := strings.Split(rawSelector, ",")
selections := make([]multiProfileSelection, 0, len(parts))
seen := make(map[string]bool, len(parts))
for _, part := range parts {
selector := strings.TrimSpace(part)
if selector == "" {
return nil, false, fmt.Errorf("--profile contains an empty profile selector: %q", rawSelector)
}
profile, err := authpkg.ResolveProfile(configDir, selector)
if err != nil {
return nil, false, err
}
if profile == nil {
return nil, false, fmt.Errorf("profile %q not found", selector)
}
if seen[profile.CorpID] {
continue
}
seen[profile.CorpID] = true
selections = append(selections, multiProfileSelection{
Selector: selector,
Profile: *profile,
})
}
if len(selections) == 0 {
return nil, false, nil
}
return selections, true, nil
}
func (r *runtimeRunner) runMultiProfile(ctx context.Context, invocation executor.Invocation, selections []multiProfileSelection) (executor.Result, error) {
previousProfile := authpkg.RuntimeProfile()
defer authpkg.SetRuntimeProfile(previousProfile)
entries := make([]any, 0, len(selections))
succeeded := 0
failed := 0
for _, selection := range selections {
authpkg.SetRuntimeProfile(selection.Profile.CorpID)
result, err := r.runSingle(ctx, cloneInvocation(invocation), false)
entry := map[string]any{
"selector": selection.Selector,
"corpId": selection.Profile.CorpID,
"corpName": selection.Profile.CorpName,
"ok": err == nil,
}
if err != nil {
failed++
entry["error"] = multiProfileErrorPayload(err)
} else {
succeeded++
if payload := multiProfileResultPayload(result); payload != nil {
entry["result"] = payload
}
if result.Response != nil {
if endpoint, ok := result.Response["endpoint"]; ok {
entry["endpoint"] = endpoint
}
}
}
entries = append(entries, entry)
}
invocation.Implemented = true
return executor.Result{
Invocation: invocation,
Response: map[string]any{
"content": map[string]any{
"success": failed == 0,
"multiProfile": true,
"summary": map[string]any{
"total": len(selections),
"succeeded": succeeded,
"failed": failed,
},
"profiles": entries,
},
},
}, nil
}
func cloneInvocation(invocation executor.Invocation) executor.Invocation {
cloned := invocation
if invocation.Params != nil {
cloned.Params = make(map[string]any, len(invocation.Params))
for key, value := range invocation.Params {
cloned.Params[key] = value
}
}
return cloned
}
func multiProfileResultPayload(result executor.Result) any {
if result.Response == nil {
return nil
}
if content, ok := result.Response["content"]; ok {
return content
}
return result.Response
}
func multiProfileErrorPayload(err error) map[string]any {
payload := map[string]any{
"message": err.Error(),
}
var typed *apperrors.Error
if errors.As(err, &typed) {
payload["category"] = string(typed.Category)
if typed.Reason != "" {
payload["reason"] = typed.Reason
}
if typed.Operation != "" {
payload["operation"] = typed.Operation
}
if code := typed.ExitCode(); code != 0 {
payload["exitCode"] = code
}
}
return payload
}
// handleCatalogMiss decides what to do when discovery catalog does not cover the
// requested product / tool and no `directRuntimeEndpoint` match fired earlier.
//
@@ -604,28 +756,40 @@ func resolveRuntimeAuthToken(ctx context.Context, explicitToken string) string {
// Cached token state for process lifetime
var (
cachedRuntimeToken string
cachedRuntimeTokenOnce sync.Once
cachedRuntimeTokenMu sync.Mutex
cachedRuntimeTokens = map[string]string{}
)
// getCachedRuntimeToken returns a cached access token, loading it only once per process.
// This avoids repeated Keychain access which takes ~70ms each time.
func getCachedRuntimeToken(ctx context.Context) string {
cachedRuntimeTokenOnce.Do(func() {
loadStart := time.Now()
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
cacheKey := strings.TrimSpace(authpkg.RuntimeProfile())
if cacheKey == "" {
cacheKey = "__default__"
}
cachedRuntimeTokenMu.Lock()
if token := cachedRuntimeTokens[cacheKey]; token != "" {
cachedRuntimeTokenMu.Unlock()
return token
}
cachedRuntimeTokenMu.Unlock()
configDir := defaultConfigDir()
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return
}
if token != "" {
cachedRuntimeToken = token
}
})
return cachedRuntimeToken
loadStart := time.Now()
defer func() { RecordTiming(ctx, "auth_keychain", time.Since(loadStart)) }()
configDir := defaultConfigDir()
token, tokenErr := resolveAccessTokenFromDir(ctx, configDir)
if tokenErr != nil && errors.Is(tokenErr, authpkg.ErrTokenDecryption) {
slog.Error(tokenErr.Error())
return ""
}
if token == "" {
return ""
}
cachedRuntimeTokenMu.Lock()
cachedRuntimeTokens[cacheKey] = token
cachedRuntimeTokenMu.Unlock()
return token
}
// generateExecutionID returns a random 16-char hex string used to correlate
@@ -640,8 +804,9 @@ func generateExecutionID() string {
// ResetRuntimeTokenCache clears the cached token, forcing a reload on next access.
// This should be called after login/logout operations.
func ResetRuntimeTokenCache() {
cachedRuntimeTokenOnce = sync.Once{}
cachedRuntimeToken = ""
cachedRuntimeTokenMu.Lock()
defer cachedRuntimeTokenMu.Unlock()
cachedRuntimeTokens = map[string]string{}
}
func newRuntimeContentScanner() safety.Scanner {
@@ -714,20 +879,21 @@ func resolveIdentityHeaders() map[string]string {
if sessionID == "" {
sessionID = os.Getenv(envRewindSessionID)
}
// Resolve the agent_code (accuracy-first; unknown hosts -> custom) and the
// per-(machine × agent_code) instance id. This is what makes agent_code
// actually report a value: previously it was sent only when the host
// injected DINGTALK_DWS_AGENTCODE (empty ~99.98% of the time), so the
// gateway logged no agent_code at all. DetectAgentCode always yields a code.
// Resolve the agent_code (accuracy-first; unknown hosts stay empty) and the
// per-(machine × agent_code) instance id when a code is known. Synthetic
// fallbacks must not be sent because PAT authorization checks use the same
// header as their grant key.
//
// Backward-compat by design (additive, not breaking):
// - x-dws-agent-id keeps its v1 meaning = machine-level install UUID
// (set by id.Headers() above), so old/new clients stay comparable.
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id.
// Old clients don't send it, which is itself a clean old/new signal.
// - x-dws-agent-instance-id is NEW: the per-(machine × agent_code) id,
// sent only when x-dingtalk-dws-agent-code is non-empty.
// Note: x-dws-channel (DWS_CHANNEL) is a separate axis, untouched.
agentCode, agentCodeSig := authpkg.DetectAgentCode()
headers["x-dws-agent-instance-id"] = id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig)
if agentInstanceID := id.ResolveAgentID(defaultConfigDir(), agentCode, agentCodeSig); agentInstanceID != "" {
headers["x-dws-agent-instance-id"] = agentInstanceID
}
// Emit the CLI version on the wire so the gateway can segment old vs new
// clients (and scope agent_code coverage / adoption). The header constant
+9 -10
View File
@@ -320,12 +320,12 @@ func TestRuntimeRunnerInjectsAuthTokenFromFlag(t *testing.T) {
func TestResolveIdentityHeadersForwardsAgentCode(t *testing.T) {
setupRuntimeCommandTest(t)
t.Setenv(authpkg.AgentCodeEnv, " cursor ")
t.Setenv(authpkg.AgentCodeEnv, " QoderWork ")
t.Setenv(authpkg.AgentCodeEnvCompat, "")
headers := resolveIdentityHeaders()
if got := headers["x-dingtalk-dws-agent-code"]; got != "cursor" {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want cursor", got)
if got := headers["x-dingtalk-dws-agent-code"]; got != "QoderWork" {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want QoderWork", got)
}
}
@@ -377,14 +377,13 @@ func TestResolveIdentityHeadersIgnoresReversedAgentCodeEnv(t *testing.T) {
headers := resolveIdentityHeaders()
// The reversed env name must never be consumed. With no canonical
// declaration and no host signature, agent_code resolves to the honest
// "custom" fallback — and crucially is NOT the reversed value.
got := headers["x-dingtalk-dws-agent-code"]
if got == "compat" {
t.Fatalf("x-dingtalk-dws-agent-code = %q, reversed env must be ignored", got)
// declaration and no host signature, agent_code stays empty rather than
// falling back to a synthetic key.
if got, ok := headers["x-dingtalk-dws-agent-code"]; ok {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want header omitted", got)
}
if got != authpkg.AgentCodeCustom {
t.Fatalf("x-dingtalk-dws-agent-code = %q, want %q (fallback)", got, authpkg.AgentCodeCustom)
if got, ok := headers["x-dws-agent-instance-id"]; ok {
t.Fatalf("x-dws-agent-instance-id = %q, want header omitted when agent_code is empty", got)
}
}
+30 -1
View File
@@ -123,7 +123,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
if filterErr != nil {
return filterErr
}
multiSkillNames = filtered
// dws-shared carries the global rules every product skill declares as a
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
}
if !autoYes {
@@ -160,6 +162,33 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
// bundle in skills/multi/ (e.g. dingtalk-aitable, dingtalk-calendar).
const multiSkillPrefix = "dingtalk-"
// multiSharedSkill is the shared, non-product skill that every per-product
// skill declares as a PREREQUISITE. It must always be installed in multi mode
// regardless of --skill / --exclude, otherwise the product skills reference a
// dws-shared that was never installed.
const multiSharedSkill = "dws-shared"
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
func ensureMandatorySharedSkill(selected, all []string) []string {
hasShared := false
for _, n := range all {
if n == multiSharedSkill {
hasShared = true
break
}
}
if !hasShared {
return selected
}
for _, n := range selected {
if n == multiSharedSkill {
return selected
}
}
return append([]string{multiSharedSkill}, selected...)
}
// normalizeMultiSkillName accepts either the short form (aitable) or the
// full form (dingtalk-aitable) and returns the canonical full form.
// Empty input returns "". Comparison is case-insensitive.
+13 -10
View File
@@ -23,7 +23,7 @@
// family (VSCODE_BRAND covers every VS Code fork, present and future).
// - Every per-host signature below is OBSERVED on a real host (live process
// env via `ps eww`, or the app bundle Info.plist), not guessed.
// - Anything unidentified falls back to AgentCodeCustom — never guess.
// - Anything unidentified stays empty — never guess or synthesize a PAT key.
// - Deliberately NOT used: TERM_PROGRAM (reports the terminal, e.g. iTerm,
// not the agent host) and fuzzy parent-process name matching.
package auth
@@ -33,7 +33,8 @@ import (
"strings"
)
// AgentCodeCustom is the honest fallback for any host we cannot identify.
// AgentCodeCustom is the literal code a host may explicitly declare for a
// custom integration. It is not used as an implicit fallback.
const AgentCodeCustom = "custom"
// hostSignature is a verified env fingerprint for a known agent host. EnvKeys
@@ -66,7 +67,7 @@ var knownSignatures = []hostSignature{
// crush, goose, kimi, amazon-q, continue, ...) expose NO reliable
// self-identifying env marker — only user-set API-key/config vars, which we
// must not key off (a user setting GEMINI_API_KEY is not "running under
// gemini"). They therefore resolve to custom unless they declare themselves.
// gemini"). They therefore resolve to empty unless they declare themselves.
//
// The authoritative, fully-general path to 100% coverage is the T0 declaration
// contract: a host sets DINGTALK_DWS_AGENTCODE=<code> when it launches dws.
@@ -78,7 +79,7 @@ var knownSignatures = []hostSignature{
// id is exposed via __CFBundleIdentifier and inherited by child processes the
// IDE spawns (including dws), so it identifies the host even from an integrated
// terminal. Verified from each app's Info.plist (2026-06-16). Only known agent
// bundles map; everything else (iTerm, Terminal, ...) falls through to custom.
// bundles map; everything else (iTerm, Terminal, ...) falls through to empty.
//
// macOS-only signal: __CFBundleIdentifier does not exist on Linux/Windows, so
// this map is simply a no-op there (os.Getenv returns "").
@@ -96,11 +97,11 @@ var bundleIDToCode = map[string]string{
// T1 verified per-agent env signature (CLI/daemon agents)
// T2 VSCODE_BRAND value (every VS Code fork declares its brand)
// T3 macOS app bundle id (known agent bundles only)
// T4 fallback -> custom (never guess)
// T4 unresolved -> empty (never guess)
func DetectAgentCode() (code string, signal string) {
// T0: host explicitly declares its agent_code — highest confidence.
if v, name := AgentCodeFromEnv(); v != "" {
return normalizeAgentCode(v), "env:" + name
return v, "env:" + name
}
// T1: verified per-agent env signature (most specific — wins over the IDE
@@ -127,8 +128,8 @@ func DetectAgentCode() (code string, signal string) {
}
}
// T4: unknown host — honest fallback, no guessing.
return AgentCodeCustom, "fallback"
// T4: unknown host — leave agent_code empty, no guessing.
return "", ""
}
// normalizeAgentCode maps host-declared names/brands to canonical agent_code
@@ -140,11 +141,13 @@ func normalizeAgentCode(raw string) string {
s = strings.ReplaceAll(s, " ", "")
switch s {
case "":
return AgentCodeCustom
return ""
case "claude", "claude-code", "claude_code", "claudecode":
return "claudecode"
case "qoder", "qoderwork":
case "qoder":
return "qoder"
case "qoderwork":
return "QoderWork"
case "workbuddy", "work-buddy":
return "workbuddy"
case "visualstudiocode", "code", "code-oss", "vscode":
+16 -17
View File
@@ -38,10 +38,10 @@ func clearAgentCodeEnv(t *testing.T) {
func TestDetectAgentCode_HostDeclaration_T0(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv(AgentCodeEnv, "Qoder")
t.Setenv(AgentCodeEnv, "QoderWork")
code, sig := DetectAgentCode()
if code != "qoder" {
t.Fatalf("want qoder, got %q", code)
if code != "QoderWork" {
t.Fatalf("want verbatim QoderWork, got %q", code)
}
if !strings.HasPrefix(sig, "env:"+AgentCodeEnv) {
t.Fatalf("want env signal, got %q", sig)
@@ -119,25 +119,24 @@ func TestDetectAgentCode_BundleID_T3(t *testing.T) {
}
}
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled — falls to
// custom.
func TestDetectAgentCode_UnknownBundleIsCustom(t *testing.T) {
// An unknown bundle id (e.g. a plain terminal) must NOT be labeled.
func TestDetectAgentCode_UnknownBundleIsEmpty(t *testing.T) {
clearAgentCodeEnv(t)
t.Setenv("__CFBundleIdentifier", "com.googlecode.iterm2")
code, _ := DetectAgentCode()
if code != AgentCodeCustom {
t.Fatalf("unknown bundle must be custom, got %q", code)
if code != "" {
t.Fatalf("unknown bundle must be empty, got %q", code)
}
}
func TestDetectAgentCode_Fallback_Custom(t *testing.T) {
func TestDetectAgentCode_FallbackEmpty(t *testing.T) {
clearAgentCodeEnv(t)
code, sig := DetectAgentCode()
if code != AgentCodeCustom {
t.Fatalf("want custom, got %q", code)
if code != "" {
t.Fatalf("want empty code, got %q", code)
}
if sig != "fallback" {
t.Fatalf("want fallback, got %q", sig)
if sig != "" {
t.Fatalf("want empty signal, got %q", sig)
}
}
@@ -147,8 +146,8 @@ func TestDetectAgentCode_IgnoresNoise(t *testing.T) {
t.Setenv("TERM_PROGRAM", "iTerm.app")
t.Setenv("DWS_CHANNEL", "Qoderwork")
code, _ := DetectAgentCode()
if code != AgentCodeCustom {
t.Fatalf("noise must not decide agent_code; want custom, got %q", code)
if code != "" {
t.Fatalf("noise must not decide agent_code; want empty, got %q", code)
}
}
@@ -172,11 +171,11 @@ func TestNormalizeAgentCode(t *testing.T) {
"claude": "claudecode",
"Claude-Code": "claudecode",
"CLAUDECODE": "claudecode",
"Qoderwork": "qoder",
"Qoderwork": "QoderWork",
"WorkBuddy": "workbuddy",
"Visual Studio Code": "vscode",
"Cursor": "cursor",
"": AgentCodeCustom,
"": "",
"some-new-ide": "some-new-ide",
}
for in, want := range cases {
+57
View File
@@ -330,6 +330,63 @@ func TestBuildTokenData_DefaultExpiry(t *testing.T) {
}
}
func TestParseMCPTokenResponseIncludesCorpName(t *testing.T) {
provider := &OAuthProvider{}
data, err := provider.parseMCPTokenResponse([]byte(`{
"accessToken": "access-123",
"refreshToken": "refresh-456",
"expiresIn": 7200,
"corpId": "ding123",
"corpName": "钉钉(中国)信息技术有限公司"
}`))
if err != nil {
t.Fatalf("parseMCPTokenResponse() error = %v", err)
}
if data.CorpID != "ding123" {
t.Fatalf("corp id = %q, want ding123", data.CorpID)
}
if data.CorpName != "钉钉(中国)信息技术有限公司" {
t.Fatalf("corp name = %q, want 钉钉(中国)信息技术有限公司", data.CorpName)
}
}
func TestParseMCPTokenResponseCorpNameFallbacks(t *testing.T) {
provider := &OAuthProvider{}
for _, tc := range []struct {
name string
body string
want string
}{
{
name: "snake",
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","corp_name":"Snake Corp"}`,
want: "Snake Corp",
},
{
name: "orgName",
body: `{"accessToken":"access","refreshToken":"refresh","expiresIn":7200,"corpId":"ding123","orgName":"Org Corp"}`,
want: "Org Corp",
},
} {
t.Run(tc.name, func(t *testing.T) {
data, err := provider.parseMCPTokenResponse([]byte(tc.body))
if err != nil {
t.Fatalf("parseMCPTokenResponse() error = %v", err)
}
if data.CorpName != tc.want {
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.want)
}
})
}
}
func TestBuildAuthURLIncludesTargetCorpID(t *testing.T) {
authURL := buildAuthURL("client-id", "http://127.0.0.1:1234/callback", "ding-target")
if !strings.Contains(authURL, "corpId=ding-target") {
t.Fatalf("auth URL missing target corpId: %s", authURL)
}
}
func buildTokenDataFromResponse(resp tokenResponse) *TokenData {
if resp.AccessToken == "" {
return nil
+3 -3
View File
@@ -134,11 +134,11 @@ func (id *Identity) machineSeed() string {
// ResolveAgentID returns the per-(machine × agentCode) agentId, deriving and
// persisting it on first sight of an agentCode. Idempotent: the same machine
// and agentCode always yields the same id, which is what makes cumulative
// per-agent_code statistics possible. An empty agentCode is treated as the
// custom bucket.
// per-agent_code statistics possible. An empty agentCode has no per-agent
// identity and returns empty.
func (id *Identity) ResolveAgentID(configDir, agentCode, signal string) string {
if agentCode == "" {
agentCode = AgentCodeCustom
return ""
}
if id.Agents == nil {
id.Agents = make(map[string]*AgentEntry)
+3 -4
View File
@@ -74,13 +74,12 @@ func TestResolveAgentID_IdempotentAndPersisted(t *testing.T) {
}
}
func TestResolveAgentID_EmptyAgentCodeGoesCustom(t *testing.T) {
func TestResolveAgentID_EmptyAgentCodeReturnsEmpty(t *testing.T) {
dir := t.TempDir()
id := EnsureExists(dir)
got := id.ResolveAgentID(dir, "", "fallback")
want := id.ResolveAgentID(dir, AgentCodeCustom, "fallback")
if got != want {
t.Fatalf("empty agent_code must map to custom bucket: %q != %q", got, want)
if got != "" {
t.Fatalf("empty agent_code must not derive an instance id, got %q", got)
}
}
+53 -3
View File
@@ -17,6 +17,7 @@ import (
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
@@ -30,6 +31,24 @@ var (
// SaveTokenDataKeychain saves TokenData to the platform keychain.
// This is the new secure storage method using random master key.
func SaveTokenDataKeychain(data *TokenData) error {
return saveTokenDataKeychainAccount(keychain.AccountToken, data)
}
// TokenAccountForCorpID returns the keychain account used for a corp-bound token.
func TokenAccountForCorpID(corpID string) string {
return keychain.AccountToken + ":" + strings.TrimSpace(corpID)
}
// SaveTokenDataKeychainForCorpID saves TokenData to a corp-scoped keychain slot.
func SaveTokenDataKeychainForCorpID(corpID string, data *TokenData) error {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return fmt.Errorf("corpId is required for profile token storage")
}
return saveTokenDataKeychainAccount(TokenAccountForCorpID(corpID), data)
}
func saveTokenDataKeychainAccount(account string, data *TokenData) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshal token data: %w", err)
@@ -41,7 +60,7 @@ func SaveTokenDataKeychain(data *TokenData) error {
}
}()
if err := keychain.Set(keychain.Service, keychain.AccountToken, string(jsonData)); err != nil {
if err := keychain.Set(keychain.Service, account, string(jsonData)); err != nil {
return fmt.Errorf("save to keychain: %w", err)
}
return nil
@@ -49,12 +68,25 @@ func SaveTokenDataKeychain(data *TokenData) error {
// LoadTokenDataKeychain loads TokenData from the platform keychain.
func LoadTokenDataKeychain() (*TokenData, error) {
jsonStr, err := keychain.Get(keychain.Service, keychain.AccountToken)
return loadTokenDataKeychainAccount(keychain.AccountToken)
}
// LoadTokenDataKeychainForCorpID loads TokenData from a corp-scoped keychain slot.
func LoadTokenDataKeychainForCorpID(corpID string) (*TokenData, error) {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return nil, fmt.Errorf("corpId is required for profile token storage")
}
return loadTokenDataKeychainAccount(TokenAccountForCorpID(corpID))
}
func loadTokenDataKeychainAccount(account string) (*TokenData, error) {
jsonStr, err := keychain.Get(keychain.Service, account)
if err != nil {
return nil, fmt.Errorf("load from keychain: %w", err)
}
if jsonStr == "" {
return nil, fmt.Errorf("no token data in keychain")
return nil, fmt.Errorf("no token data in keychain account %q", account)
}
var data TokenData
@@ -69,11 +101,29 @@ func DeleteTokenDataKeychain() error {
return keychain.Remove(keychain.Service, keychain.AccountToken)
}
// DeleteTokenDataKeychainForCorpID removes TokenData from a corp-scoped keychain slot.
func DeleteTokenDataKeychainForCorpID(corpID string) error {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return fmt.Errorf("corpId is required for profile token storage")
}
return keychain.Remove(keychain.Service, TokenAccountForCorpID(corpID))
}
// TokenDataExistsKeychain checks if token data exists in keychain.
func TokenDataExistsKeychain() bool {
return keychain.Exists(keychain.Service, keychain.AccountToken)
}
// TokenDataExistsKeychainForCorpID checks if a corp-scoped token exists.
func TokenDataExistsKeychainForCorpID(corpID string) bool {
corpID = strings.TrimSpace(corpID)
if corpID == "" {
return false
}
return keychain.Exists(keychain.Service, TokenAccountForCorpID(corpID))
}
// EnsureMigration performs one-time migration from legacy .data to keychain.
// This should be called early in the auth flow (e.g., during GetAccessToken).
// The migration is idempotent and thread-safe.
+31 -6
View File
@@ -23,6 +23,7 @@ import (
"net/url"
"os"
"slices"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
@@ -143,9 +144,13 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.CorpName = data.CorpName
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
if err := SaveTokenData(p.configDir, updated); err != nil {
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
@@ -185,9 +190,13 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
updated.CorpID = data.CorpID
updated.UserID = data.UserID
updated.UserName = data.UserName
updated.CorpName = data.CorpName
if updated.CorpName == "" {
updated.CorpName = data.CorpName
}
if err := SaveTokenData(p.configDir, updated); err != nil {
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
if err := saveTokenDataLocked(p.configDir, updated); err != nil {
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
}
return updated, nil
@@ -259,7 +268,7 @@ func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
}
// parseMCPTokenResponse parses token response from MCP proxy.
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "..."}
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
var resp struct {
AccessToken string `json:"accessToken"`
@@ -267,6 +276,9 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
PersistentCode string `json:"persistentCode"`
ExpiresIn int64 `json:"expiresIn"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
CorpNameSnake string `json:"corp_name"`
OrgName string `json:"orgName"`
// Error fields (when request fails)
ErrorCode string `json:"errorCode,omitempty"`
ErrorMsg string `json:"errorMsg,omitempty"`
@@ -293,6 +305,7 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
CorpID: resp.CorpID,
CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName),
}
if resp.PersistentCode != "" {
data.PersistentCode = resp.PersistentCode
@@ -300,7 +313,16 @@ func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
return data, nil
}
func buildAuthURL(clientID, redirectURI string) string {
func firstNonEmpty(values ...string) string {
for _, v := range values {
if trimmed := strings.TrimSpace(v); trimmed != "" {
return trimmed
}
}
return ""
}
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
params := url.Values{
"client_id": {clientID},
"redirect_uri": {redirectURI},
@@ -308,6 +330,9 @@ func buildAuthURL(clientID, redirectURI string) string {
"scope": {DefaultScopes},
"prompt": {"consent"},
}
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
params.Set("corpId", targetCorpID)
}
return AuthorizeURL + "?" + params.Encode()
}
+11 -7
View File
@@ -37,12 +37,13 @@ var oauthHTTPClient = &http.Client{
// OAuthProvider handles the DingTalk OAuth 2.0 authorization code flow.
type OAuthProvider struct {
configDir string
clientID string
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
configDir string
clientID string
logger *slog.Logger
Output io.Writer
httpClient *http.Client
NoBrowser bool
TargetCorpID string
}
// NewOAuthProvider creates a new OAuth provider.
@@ -397,7 +398,7 @@ func (p *OAuthProvider) Login(ctx context.Context, force bool) (*TokenData, erro
_ = server.Shutdown(shutCtx)
}()
authURL := buildAuthURL(p.clientID, redirectURI)
authURL := buildAuthURL(p.clientID, redirectURI, p.TargetCorpID)
if p.logger != nil {
p.logger.Debug("authorization URL", "url", authURL)
}
@@ -547,9 +548,12 @@ func (p *OAuthProvider) GetAccessToken(ctx context.Context) (string, error) {
if rErr == nil {
return refreshed.AccessToken, nil
}
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
if p.logger != nil {
p.logger.Warn(i18n.T("refresh_token 刷新失败"), "error", rErr)
}
} else {
_ = MarkProfileStatus(p.configDir, data.CorpID, ProfileStatusExpired)
}
return "", errors.New(i18n.T("所有凭证已失效,请运行 dws auth login 重新登录"))
+4 -1
View File
@@ -52,6 +52,9 @@ func PortableAuthTargetPopulated(configDir string) bool {
if TokenDataExistsKeychain() {
return true
}
if _, err := os.Stat(ProfilesPath(configDir)); err == nil {
return true
}
if _, err := os.Stat(filepath.Join(configDir, "app.json")); err == nil {
return true
}
@@ -199,7 +202,7 @@ func ImportPortableAuthBundle(configDir string, r io.Reader) (PortableImportRepo
func portableConfigFiles(configDir string) ([]string, error) {
var files []string
patterns := []string{"app*.json", "mcp_url", "terminal_url"}
patterns := []string{"app*.json", profilesJSONFile, "mcp_url", "terminal_url"}
for _, pattern := range patterns {
matches, err := filepath.Glob(filepath.Join(configDir, pattern))
if err != nil {
+73
View File
@@ -138,3 +138,76 @@ func TestPortableAuthBundleRoundTripPreservesRefreshToken(t *testing.T) {
t.Fatalf("imported app config = %#v, want client ID preserved", cfg)
}
}
func TestPortableAuthBundleRoundTripPreservesProfiles(t *testing.T) {
t.Setenv(keychain.DisableKeychainEnv, "1")
SetRuntimeProfile("")
t.Cleanup(func() { SetRuntimeProfile("") })
sourceKeychain := filepath.Join(t.TempDir(), "source-keychain")
t.Setenv(keychain.StorageDirEnv, sourceKeychain)
sourceConfig := filepath.Join(t.TempDir(), ".dws")
tokenA := &TokenData{
AccessToken: "access-a",
RefreshToken: "refresh-a",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
CorpID: "corp_a",
CorpName: "A Org",
ClientID: "client-a",
}
tokenB := &TokenData{
AccessToken: "access-b",
RefreshToken: "refresh-b",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(30 * 24 * time.Hour),
CorpID: "corp_b",
CorpName: "B Org",
ClientID: "client-b",
}
if err := SaveTokenData(sourceConfig, tokenA); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(sourceConfig, tokenB); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
var bundle bytes.Buffer
if err := ExportPortableAuthBundle(sourceConfig, &bundle); err != nil {
t.Fatalf("ExportPortableAuthBundle() error = %v", err)
}
targetKeychain := filepath.Join(t.TempDir(), "target-keychain")
t.Setenv(keychain.StorageDirEnv, targetKeychain)
targetConfig := filepath.Join(t.TempDir(), ".dws")
if _, err := ImportPortableAuthBundle(targetConfig, bytes.NewReader(bundle.Bytes())); err != nil {
t.Fatalf("ImportPortableAuthBundle() error = %v", err)
}
cfg, err := LoadProfiles(targetConfig)
if err != nil {
t.Fatalf("LoadProfiles() after import error = %v", err)
}
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
t.Fatalf("profiles after import = %#v", cfg)
}
if len(cfg.Profiles) != 2 {
t.Fatalf("profiles len = %d, want 2: %#v", len(cfg.Profiles), cfg.Profiles)
}
loadedA, err := LoadTokenDataForProfile(targetConfig, "corp_a")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(A) after import error = %v", err)
}
if loadedA.AccessToken != "access-a" {
t.Fatalf("profile A token = %q, want access-a", loadedA.AccessToken)
}
loadedB, err := LoadTokenDataForProfile(targetConfig, "corp_b")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(B) after import error = %v", err)
}
if loadedB.AccessToken != "access-b" {
t.Fatalf("profile B token = %q, want access-b", loadedB.AccessToken)
}
}
+678
View File
@@ -0,0 +1,678 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package auth
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
)
// withProfilesLock runs fn while holding the auth dual-layer lock (process +
// cross-process file lock) so that all read-modify-write cycles on
// profiles.json and the legacy token mirror are serialized.
//
// The lock is NOT reentrant. fn must only call the lock-free *Locked variants;
// calling a public (locking) function from within fn would deadlock. Paths that
// already hold the lock (e.g. OAuthProvider.lockedRefresh and the read path
// reached from it) must likewise call the lock-free variants directly.
func withProfilesLock(configDir string, fn func() error) error {
lock, err := AcquireDualLock(context.Background(), configDir)
if err != nil {
return err
}
defer lock.Release()
return fn()
}
const profilesJSONFile = "profiles.json"
const (
ProfileStatusActive = "active"
ProfileStatusExpired = "expired"
ProfileStatusRevoked = "revoked"
)
// ProfilesConfig stores non-sensitive profile metadata. Token material stays in keychain.
type ProfilesConfig struct {
Version int `json:"version"`
PrimaryProfile string `json:"primaryProfile,omitempty"`
CurrentProfile string `json:"currentProfile,omitempty"`
PreviousProfile string `json:"previousProfile,omitempty"`
Profiles []Profile `json:"profiles,omitempty"`
}
// Profile is a logged-in DingTalk organization identity.
type Profile struct {
Name string `json:"name"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName,omitempty"`
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"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
var (
runtimeProfileMu sync.RWMutex
runtimeProfile string
)
// SetRuntimeProfile sets a process-local one-shot profile override.
func SetRuntimeProfile(profile string) {
runtimeProfileMu.Lock()
defer runtimeProfileMu.Unlock()
runtimeProfile = strings.TrimSpace(profile)
}
// RuntimeProfile returns the process-local one-shot profile override.
func RuntimeProfile() string {
runtimeProfileMu.RLock()
defer runtimeProfileMu.RUnlock()
return runtimeProfile
}
// ProfilesPath returns the profile metadata path for a config dir.
func ProfilesPath(configDir string) string {
return filepath.Join(configDir, profilesJSONFile)
}
// LoadProfiles reads profiles.json. A missing file returns an empty config.
func LoadProfiles(configDir string) (*ProfilesConfig, error) {
path := ProfilesPath(configDir)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &ProfilesConfig{Version: 1}, nil
}
return nil, fmt.Errorf("read profiles: %w", err)
}
var cfg ProfilesConfig
if err := json.Unmarshal(data, &cfg); err != nil {
// Corrupt file (e.g. an interrupted concurrent write): quarantine it and
// rebuild an empty config so the CLI can self-heal (auth reset / re-login)
// instead of being permanently locked out by an unreadable profiles.json.
quarantine := path + ".corrupt-" + time.Now().Format("20060102-150405.000")
_ = os.Rename(path, quarantine)
return &ProfilesConfig{Version: 1}, nil
}
normalizeProfilesConfig(&cfg)
return &cfg, nil
}
// SaveProfiles writes profiles.json atomically.
func SaveProfiles(configDir string, cfg *ProfilesConfig) error {
if cfg == nil {
cfg = &ProfilesConfig{}
}
normalizeProfilesConfig(cfg)
if err := os.MkdirAll(configDir, config.DirPerm); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal profiles: %w", err)
}
data = append(data, '\n')
path := ProfilesPath(configDir)
// Per-write random temp name: a fixed "profiles.json.tmp" lets two
// concurrent writers interleave into the same temp file and rename a
// corrupted result into place.
tmp := path + "." + uuid.New().String() + ".tmp"
if err := os.WriteFile(tmp, data, config.FilePerm); err != nil {
return fmt.Errorf("write profiles tmp: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("rename profiles: %w", err)
}
return nil
}
// EnsureProfilesMigration initializes profiles.json from the legacy auth-token slot when needed.
// EnsureProfilesMigration migrates a legacy single-slot token into the
// profiles registry. It acquires the lock; call ensureProfilesMigrationLocked
// from contexts that already hold it (refresh / read paths).
func EnsureProfilesMigration(configDir string) error {
return withProfilesLock(configDir, func() error {
return ensureProfilesMigrationLocked(configDir)
})
}
func ensureProfilesMigrationLocked(configDir string) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
if len(cfg.Profiles) > 0 {
return nil
}
if !TokenDataExistsKeychain() {
return nil
}
data, err := LoadTokenDataKeychain()
if err != nil || data == nil || strings.TrimSpace(data.CorpID) == "" {
return nil
}
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
return err
}
return upsertProfileFromToken(configDir, cfg, data, false)
}
// UpsertProfileFromToken updates profiles.json after a successful login or refresh.
func UpsertProfileFromToken(configDir string, data *TokenData) error {
return UpsertProfileFromTokenWithCurrent(configDir, data, true)
}
// UpsertProfileFromTokenWithCurrent updates profiles.json and optionally makes
// the token's corp the persistent current profile.
func UpsertProfileFromTokenWithCurrent(configDir string, data *TokenData, makeCurrent bool) error {
return withProfilesLock(configDir, func() error {
return upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent)
})
}
func upsertProfileFromTokenWithCurrentLocked(configDir string, data *TokenData, makeCurrent bool) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
return upsertProfileFromToken(configDir, cfg, data, makeCurrent)
}
func upsertProfileFromToken(configDir string, cfg *ProfilesConfig, data *TokenData, makeCurrent bool) error {
if data == nil {
return nil
}
corpID := strings.TrimSpace(data.CorpID)
if corpID == "" {
return nil
}
normalizeProfilesConfig(cfg)
now := time.Now().Format(time.RFC3339)
idx := profileIndexByCorpID(cfg, corpID)
if idx < 0 {
profile := Profile{
Name: chooseProfileName(cfg, data),
CorpID: corpID,
CorpName: strings.TrimSpace(data.CorpName),
UserID: strings.TrimSpace(data.UserID),
UserName: strings.TrimSpace(data.UserName),
ClientID: strings.TrimSpace(data.ClientID),
Status: ProfileStatusActive,
ExpiresAt: timeOrRFC3339(data.ExpiresAt),
RefreshExpAt: timeOrRFC3339(data.RefreshExpAt),
LastLoginAt: now,
LastUsedAt: now,
UpdatedAt: now,
}
cfg.Profiles = append(cfg.Profiles, profile)
} else {
p := &cfg.Profiles[idx]
if shouldRefreshProfileName(p, data) {
p.Name = chooseProfileName(cfg, data)
}
if v := strings.TrimSpace(data.CorpName); v != "" {
p.CorpName = v
}
if v := strings.TrimSpace(data.UserID); v != "" {
p.UserID = v
}
if v := strings.TrimSpace(data.UserName); v != "" {
p.UserName = v
}
if v := strings.TrimSpace(data.ClientID); v != "" {
p.ClientID = v
}
p.Status = ProfileStatusActive
p.ExpiresAt = timeOrRFC3339(data.ExpiresAt)
p.RefreshExpAt = timeOrRFC3339(data.RefreshExpAt)
p.LastLoginAt = now
p.LastUsedAt = now
p.UpdatedAt = now
}
if cfg.PrimaryProfile == "" {
cfg.PrimaryProfile = corpID
}
if makeCurrent && cfg.CurrentProfile != corpID {
if cfg.CurrentProfile != "" {
cfg.PreviousProfile = cfg.CurrentProfile
}
cfg.CurrentProfile = corpID
}
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = corpID
}
return SaveProfiles(configDir, cfg)
}
// ResolveProfile returns a profile selected by name/corpId or by current/primary fallback.
func ResolveProfile(configDir, selector string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
selector = strings.TrimSpace(selector)
if selector != "" {
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", selector)
}
return p, nil
}
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
return p, nil
}
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
return p, nil
}
return nil, nil
}
func resolveProfileForLoad(configDir, selector string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
selector = strings.TrimSpace(selector)
if selector != "" {
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", selector)
}
return p, nil
}
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
if p := findProfile(cfg, candidate); p != nil && TokenDataExistsKeychainForCorpID(p.CorpID) {
return p, nil
}
}
if p := findProfile(cfg, cfg.CurrentProfile); p != nil {
return p, nil
}
if p := findProfile(cfg, cfg.PrimaryProfile); p != nil {
return p, nil
}
return nil, nil
}
// SetCurrentProfile persists the selected current profile.
func SetCurrentProfile(configDir, selector string) (*Profile, error) {
var result *Profile
err := withProfilesLock(configDir, func() error {
p, e := setCurrentProfileLocked(configDir, selector)
result = p
return e
})
return result, err
}
func setCurrentProfileLocked(configDir, selector string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
}
if cfg.CurrentProfile != p.CorpID {
if cfg.CurrentProfile != "" {
cfg.PreviousProfile = cfg.CurrentProfile
}
cfg.CurrentProfile = p.CorpID
}
touchProfile(cfg, p.CorpID)
if err := SaveProfiles(configDir, cfg); err != nil {
return nil, err
}
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
return nil, err
}
return findProfile(cfg, p.CorpID), nil
}
// UsePreviousProfile toggles currentProfile and previousProfile.
func UsePreviousProfile(configDir string) (*Profile, error) {
var result *Profile
err := withProfilesLock(configDir, func() error {
p, e := usePreviousProfileLocked(configDir)
result = p
return e
})
return result, err
}
func usePreviousProfileLocked(configDir string) (*Profile, error) {
if err := ensureProfilesMigrationLocked(configDir); err != nil {
return nil, err
}
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
prev := strings.TrimSpace(cfg.PreviousProfile)
if prev == "" {
return nil, fmt.Errorf("previous profile is empty")
}
p := findProfile(cfg, prev)
if p == nil {
return nil, fmt.Errorf("previous profile %q not found", prev)
}
cfg.PreviousProfile, cfg.CurrentProfile = cfg.CurrentProfile, p.CorpID
touchProfile(cfg, p.CorpID)
if err := SaveProfiles(configDir, cfg); err != nil {
return nil, err
}
if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
return nil, err
}
return findProfile(cfg, p.CorpID), nil
}
// RemoveProfile removes a profile from metadata and returns the removed profile.
func RemoveProfile(configDir, selector string) (*Profile, error) {
var result *Profile
err := withProfilesLock(configDir, func() error {
p, e := removeProfileLocked(configDir, selector)
result = p
return e
})
return result, err
}
func removeProfileLocked(configDir, selector string) (*Profile, error) {
cfg, err := LoadProfiles(configDir)
if err != nil {
return nil, err
}
p := findProfile(cfg, selector)
if p == nil {
return nil, fmt.Errorf("profile %q not found", strings.TrimSpace(selector))
}
removed := *p
kept := cfg.Profiles[:0]
for _, profile := range cfg.Profiles {
if profile.CorpID != removed.CorpID {
kept = append(kept, profile)
}
}
cfg.Profiles = kept
if cfg.PrimaryProfile == removed.CorpID {
cfg.PrimaryProfile = firstProfileCorpID(cfg)
}
if cfg.CurrentProfile == removed.CorpID {
cfg.CurrentProfile = cfg.PrimaryProfile
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = firstProfileCorpID(cfg)
}
}
if cfg.PreviousProfile == removed.CorpID {
cfg.PreviousProfile = ""
}
if len(cfg.Profiles) == 0 {
cfg.PrimaryProfile = ""
cfg.CurrentProfile = ""
cfg.PreviousProfile = ""
}
if err := SaveProfiles(configDir, cfg); err != nil {
return nil, err
}
return &removed, nil
}
// MarkProfileStatus updates a profile status if it exists.
func MarkProfileStatus(configDir, corpID, status string) error {
if strings.TrimSpace(corpID) == "" {
return nil
}
return withProfilesLock(configDir, func() error {
return markProfileStatusLocked(configDir, corpID, status)
})
}
func markProfileStatusLocked(configDir, corpID, status string) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
p := findProfile(cfg, corpID)
if p == nil {
return nil
}
p.Status = strings.TrimSpace(status)
p.UpdatedAt = time.Now().Format(time.RFC3339)
return SaveProfiles(configDir, cfg)
}
// SyncLegacyTokenMirror mirrors the current profile token into legacy auth-token.
func SyncLegacyTokenMirror(configDir string) error {
return withProfilesLock(configDir, func() error {
return syncLegacyTokenMirrorLocked(configDir)
})
}
func syncLegacyTokenMirrorLocked(configDir string) error {
cfg, err := LoadProfiles(configDir)
if err != nil {
return err
}
hadReadError := false
for _, candidate := range []string{cfg.CurrentProfile, cfg.PrimaryProfile} {
p := findProfile(cfg, candidate)
if p == nil {
continue
}
data, loadErr := LoadTokenDataKeychainForCorpID(p.CorpID)
if loadErr != nil {
// Transient keychain read failure: do NOT touch the existing mirror.
hadReadError = true
continue
}
if data != nil {
if err := SaveTokenDataKeychain(data); err != nil {
return err
}
return WriteTokenMarker(configDir)
}
}
if hadReadError {
// Keep the existing legacy mirror untouched rather than wiping a host
// app's login state just because keychain was momentarily unavailable.
return nil
}
// All candidate profiles confirmed absent (no token): clear the mirror.
_ = DeleteTokenDataKeychain()
_ = DeleteTokenMarker(configDir)
return nil
}
func normalizeProfilesConfig(cfg *ProfilesConfig) {
if cfg == nil {
return
}
cfg.Version = 1
seen := make(map[string]bool, len(cfg.Profiles))
profiles := cfg.Profiles[:0]
for _, p := range cfg.Profiles {
p.CorpID = strings.TrimSpace(p.CorpID)
if p.CorpID == "" || seen[p.CorpID] {
continue
}
seen[p.CorpID] = true
p.Name = strings.TrimSpace(p.Name)
if p.Name == "" {
p.Name = p.CorpID
}
if corpName := strings.TrimSpace(p.CorpName); p.Name == p.CorpID && corpName != "" && !profileNameTakenByOtherCorp(cfg, corpName, p.CorpID) {
p.Name = corpName
}
if p.Status == "" {
p.Status = ProfileStatusActive
}
profiles = append(profiles, p)
}
cfg.Profiles = profiles
if cfg.PrimaryProfile != "" && findProfile(cfg, cfg.PrimaryProfile) == nil {
cfg.PrimaryProfile = ""
}
if cfg.CurrentProfile != "" && findProfile(cfg, cfg.CurrentProfile) == nil {
cfg.CurrentProfile = ""
}
if cfg.PreviousProfile != "" && findProfile(cfg, cfg.PreviousProfile) == nil {
cfg.PreviousProfile = ""
}
if cfg.PrimaryProfile == "" {
cfg.PrimaryProfile = firstProfileCorpID(cfg)
}
if cfg.CurrentProfile == "" {
cfg.CurrentProfile = cfg.PrimaryProfile
}
}
func chooseProfileName(cfg *ProfilesConfig, data *TokenData) string {
base := strings.TrimSpace(data.CorpName)
if base == "" {
base = strings.TrimSpace(data.CorpID)
}
if base == "" {
base = "profile"
}
if !profileNameTakenByOtherCorp(cfg, base, data.CorpID) {
return base
}
suffix := shortCorpID(data.CorpID)
name := base + "-" + suffix
if !profileNameTakenByOtherCorp(cfg, name, data.CorpID) {
return name
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%s-%d", base, suffix, i)
if !profileNameTakenByOtherCorp(cfg, candidate, data.CorpID) {
return candidate
}
}
}
func shouldRefreshProfileName(p *Profile, data *TokenData) bool {
if p == nil || data == nil {
return false
}
name := strings.TrimSpace(p.Name)
if name == "" {
return true
}
return strings.TrimSpace(data.CorpName) != "" && name == strings.TrimSpace(p.CorpID)
}
func profileNameTakenByOtherCorp(cfg *ProfilesConfig, name, corpID string) bool {
name = strings.TrimSpace(name)
corpID = strings.TrimSpace(corpID)
for _, p := range cfg.Profiles {
if p.CorpID != corpID && p.Name == name {
return true
}
}
return false
}
func findProfile(cfg *ProfilesConfig, selector string) *Profile {
if cfg == nil {
return nil
}
selector = strings.TrimSpace(selector)
if selector == "" {
return nil
}
var corpNameMatch *Profile
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == selector || cfg.Profiles[i].Name == selector {
return &cfg.Profiles[i]
}
if strings.TrimSpace(cfg.Profiles[i].CorpName) == selector {
if corpNameMatch != nil {
return nil
}
corpNameMatch = &cfg.Profiles[i]
}
}
return corpNameMatch
}
func profileIndexByCorpID(cfg *ProfilesConfig, corpID string) int {
if cfg == nil {
return -1
}
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == corpID {
return i
}
}
return -1
}
func firstProfileCorpID(cfg *ProfilesConfig) string {
if cfg == nil || len(cfg.Profiles) == 0 {
return ""
}
return cfg.Profiles[0].CorpID
}
func touchProfile(cfg *ProfilesConfig, corpID string) {
if p := findProfile(cfg, corpID); p != nil {
now := time.Now().Format(time.RFC3339)
p.LastUsedAt = now
p.UpdatedAt = now
}
}
func timeOrRFC3339(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func shortCorpID(corpID string) string {
corpID = strings.TrimSpace(corpID)
if len(corpID) <= 8 {
return corpID
}
return corpID[len(corpID)-8:]
}
+167 -10
View File
@@ -22,8 +22,11 @@ import (
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -82,7 +85,7 @@ func WriteTokenMarker(configDir string) error {
if err := os.MkdirAll(configDir, 0o700); err != nil {
return err
}
tmp := filepath.Join(configDir, tokenJSONFile+".tmp")
tmp := filepath.Join(configDir, tokenJSONFile+"."+uuid.New().String()+".tmp")
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return err
}
@@ -91,7 +94,10 @@ func WriteTokenMarker(configDir string) error {
// DeleteTokenMarker removes the token.json marker file.
func DeleteTokenMarker(configDir string) error {
return os.Remove(filepath.Join(configDir, tokenJSONFile))
if err := os.Remove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
@@ -99,20 +105,67 @@ func DeleteTokenMarker(configDir string) error {
// to the default keychain-based storage.
func SaveTokenData(configDir string, data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshaling token data for hook: %w", err)
}
return h.SaveToken(configDir, jsonData)
return saveTokenViaHook(h, configDir, data)
}
return SaveTokenDataKeychain(data)
return withProfilesLock(configDir, func() error {
return saveTokenDataLocked(configDir, data)
})
}
// saveTokenDataLocked performs the keychain + profiles.json + legacy mirror
// writes assuming the auth dual-layer lock is already held. Callers that
// already hold the lock (OAuthProvider refresh path, the legacy secure->keychain
// migration in LoadTokenDataForProfile) must use this instead of SaveTokenData
// to avoid deadlocking on the non-reentrant lock.
func saveTokenDataLocked(configDir string, data *TokenData) error {
if h := edition.Get(); h.SaveToken != nil {
return saveTokenViaHook(h, configDir, data)
}
if data != nil && strings.TrimSpace(data.CorpID) != "" {
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
return err
}
makeCurrent := strings.TrimSpace(RuntimeProfile()) == ""
if err := upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent); err != nil {
return err
}
if makeCurrent {
if err := SaveTokenDataKeychain(data); err != nil {
return err
}
} else if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
return err
}
return WriteTokenMarker(configDir)
}
if err := SaveTokenDataKeychain(data); err != nil {
return err
}
return WriteTokenMarker(configDir)
}
func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error {
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("marshaling token data for hook: %w", err)
}
return h.SaveToken(configDir, jsonData)
}
// LoadTokenData reads TokenData. When an edition hook (LoadToken) is
// registered, it delegates entirely to the hook; otherwise it falls back
// to keychain with legacy .data migration.
func LoadTokenData(configDir string) (*TokenData, error) {
return LoadTokenDataForProfile(configDir, RuntimeProfile())
}
// LoadTokenDataForProfile reads TokenData for a profile selector without mutating
// currentProfile. Empty selector follows the default resolution chain.
func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
if h := edition.Get(); h.LoadToken != nil {
if strings.TrimSpace(profile) != "" {
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
}
jsonData, err := h.LoadToken(configDir)
if err != nil {
return nil, err
@@ -125,6 +178,28 @@ func LoadTokenData(configDir string) (*TokenData, error) {
}
// Default: keychain with legacy .data migration
selected, err := resolveProfileForLoad(configDir, profile)
if err != nil {
return nil, err
}
if selected != nil {
data, err := LoadTokenDataKeychainForCorpID(selected.CorpID)
if err == nil {
return data, nil
}
if strings.TrimSpace(profile) != "" {
return nil, err
}
// No explicit --profile: `selected` is the resolved current/primary
// profile. Only fall back to the legacy single slot when it belongs to
// the SAME org; otherwise surface the error instead of silently acting
// as a different organization (the legacy mirror may have drifted).
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
return legacy, nil
}
return nil, err
}
if TokenDataExistsKeychain() {
return LoadTokenDataKeychain()
}
@@ -132,7 +207,9 @@ func LoadTokenData(configDir string) (*TokenData, error) {
if err != nil {
return nil, err
}
if err := SaveTokenDataKeychain(data); err == nil {
// One-time legacy secure-store -> keychain migration. This read path may run
// while the refresh lock is already held, so use the lock-free saver.
if err := saveTokenDataLocked(configDir, data); err == nil {
_ = DeleteSecureData(configDir)
}
return data, nil
@@ -142,15 +219,95 @@ func LoadTokenData(configDir string) (*TokenData, error) {
// registered, it delegates entirely to the hook; otherwise it falls back
// to keychain + legacy cleanup.
func DeleteTokenData(configDir string) error {
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
}
// DeleteTokenDataForProfile removes one profile's token data. Empty selector
// removes the current/default profile, falling back to legacy single-slot auth.
func DeleteTokenDataForProfile(configDir, profile string) error {
if h := edition.Get(); h.DeleteToken != nil {
if strings.TrimSpace(profile) != "" {
return fmt.Errorf("profile selection is not supported by the current auth backend")
}
return h.DeleteToken(configDir)
}
return withProfilesLock(configDir, func() error {
return deleteTokenDataForProfileLocked(configDir, profile)
})
}
func deleteTokenDataForProfileLocked(configDir, profile string) error {
selected, err := resolveProfileForLoad(configDir, profile)
if err != nil {
return err
}
if selected != nil {
keychainErr := DeleteTokenDataKeychainForCorpID(selected.CorpID)
_, removeErr := removeProfileLocked(configDir, selected.CorpID)
legacyErr := syncLegacyTokenMirrorLocked(configDir)
secureErr := DeleteSecureData(configDir)
if keychainErr != nil {
return keychainErr
}
if removeErr != nil {
return removeErr
}
if legacyErr != nil {
return legacyErr
}
return secureErr
}
keychainErr := DeleteTokenDataKeychain()
legacyErr := DeleteSecureData(configDir)
markerErr := DeleteTokenMarker(configDir)
if keychainErr != nil {
return keychainErr
}
return legacyErr
if legacyErr != nil {
return legacyErr
}
return markerErr
}
// DeleteAllTokenData removes all profile-scoped and legacy token data.
func DeleteAllTokenData(configDir string) error {
if h := edition.Get(); h.DeleteToken != nil {
return h.DeleteToken(configDir)
}
return withProfilesLock(configDir, func() error {
var firstErr error
// Best-effort: even if profiles.json is unreadable, still clear every
// other slot so the user can always self-heal via auth reset / logout.
if cfg, err := LoadProfiles(configDir); err == nil {
for _, profile := range cfg.Profiles {
if e := DeleteTokenDataKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
firstErr = e
}
}
}
if e := os.Remove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
firstErr = e
}
// Sweep any quarantined corrupt-profiles files so they don't accumulate.
if matches, _ := filepath.Glob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 {
for _, m := range matches {
if e := os.Remove(m); e != nil && !os.IsNotExist(e) && firstErr == nil {
firstErr = e
}
}
}
if e := DeleteTokenDataKeychain(); e != nil && firstErr == nil {
firstErr = e
}
if e := DeleteSecureData(configDir); e != nil && firstErr == nil {
firstErr = e
}
if e := DeleteTokenMarker(configDir); e != nil && firstErr == nil {
firstErr = e
}
return firstErr
})
}
// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token.
+283
View File
@@ -14,6 +14,7 @@
package auth
import (
"os"
"testing"
"time"
@@ -25,8 +26,10 @@ import (
// written by these tests, and removes test data on completion.
func cleanupKeychain(t *testing.T) {
t.Helper()
SetRuntimeProfile("")
t.Setenv(keychain.StorageDirEnv, t.TempDir())
t.Cleanup(func() {
SetRuntimeProfile("")
_ = keychain.Remove(keychain.Service, keychain.AccountToken)
})
}
@@ -127,6 +130,271 @@ func TestTokenOverwrite(t *testing.T) {
}
}
func TestMultiProfileSaveLoadAndSwitch(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
dataA := testToken("at_a", "corp_a", "A Org")
dataB := testToken("at_b", "corp_b", "B Org")
if err := SaveTokenData(configDir, dataA); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, dataB); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_a" || cfg.CurrentProfile != "corp_b" || cfg.PreviousProfile != "corp_a" {
t.Fatalf("profile pointers = primary %q current %q previous %q", cfg.PrimaryProfile, cfg.CurrentProfile, cfg.PreviousProfile)
}
loadedB, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loadedB.AccessToken != "at_b" {
t.Fatalf("default token = %q, want at_b", loadedB.AccessToken)
}
loadedA, err := LoadTokenDataForProfile(configDir, "A Org")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(A Org) error = %v", err)
}
if loadedA.AccessToken != "at_a" {
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
}
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
t.Fatalf("SetCurrentProfile(A) error = %v", err)
}
loadedA, err = LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() after switch error = %v", err)
}
if loadedA.AccessToken != "at_a" {
t.Fatalf("default token after switch = %q, want at_a", loadedA.AccessToken)
}
if _, err := UsePreviousProfile(configDir); err != nil {
t.Fatalf("UsePreviousProfile() error = %v", err)
}
loadedB, err = LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() after previous error = %v", err)
}
if loadedB.AccessToken != "at_b" {
t.Fatalf("default token after previous = %q, want at_b", loadedB.AccessToken)
}
}
func TestRuntimeProfileOverrideDoesNotMutateCurrent(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
if _, err := SetCurrentProfile(configDir, "corp_a"); err != nil {
t.Fatalf("SetCurrentProfile(A) error = %v", err)
}
SetRuntimeProfile("corp_b")
if err := SaveTokenData(configDir, testToken("at_b_refreshed", "corp_b", "B Org")); err != nil {
t.Fatalf("SaveTokenData(B refresh) error = %v", err)
}
SetRuntimeProfile("")
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.CurrentProfile != "corp_a" {
t.Fatalf("current profile = %q, want corp_a", cfg.CurrentProfile)
}
loadedB, err := LoadTokenDataForProfile(configDir, "corp_b")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(B) error = %v", err)
}
if loadedB.AccessToken != "at_b_refreshed" {
t.Fatalf("profile B token = %q, want at_b_refreshed", loadedB.AccessToken)
}
loadedDefault, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loadedDefault.AccessToken != "at_a" {
t.Fatalf("default token = %q, want at_a", loadedDefault.AccessToken)
}
}
func TestDeleteProfilePreservesOtherProfiles(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
if err := SaveTokenData(configDir, testToken("at_a", "corp_a", "A Org")); err != nil {
t.Fatalf("SaveTokenData(A) error = %v", err)
}
if err := SaveTokenData(configDir, testToken("at_b", "corp_b", "B Org")); err != nil {
t.Fatalf("SaveTokenData(B) error = %v", err)
}
if err := DeleteTokenDataForProfile(configDir, "corp_b"); err != nil {
t.Fatalf("DeleteTokenDataForProfile(B) error = %v", err)
}
if _, err := LoadTokenDataForProfile(configDir, "corp_b"); err == nil {
t.Fatal("LoadTokenDataForProfile(B) error = nil after delete, want failure")
}
loadedA, err := LoadTokenDataForProfile(configDir, "corp_a")
if err != nil {
t.Fatalf("LoadTokenDataForProfile(A) error = %v", err)
}
if loadedA.AccessToken != "at_a" {
t.Fatalf("profile A token = %q, want at_a", loadedA.AccessToken)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 || cfg.CurrentProfile != "corp_a" {
t.Fatalf("profiles after delete = %#v", cfg)
}
}
func TestUpsertProfileFromTokenOverwritesSameCorp(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
first := testToken("at_first", "corp_same", "旧组织名")
if err := SaveTokenData(configDir, first); err != nil {
t.Fatalf("SaveTokenData(first) error = %v", err)
}
second := testToken("at_second", "corp_same", "新组织名")
second.UserID = "user_updated"
second.UserName = "Updated User"
second.ClientID = "client_updated"
if err := SaveTokenData(configDir, second); err != nil {
t.Fatalf("SaveTokenData(second) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
}
profile := cfg.Profiles[0]
if profile.CorpName != "新组织名" {
t.Fatalf("corpName = %q, want 新组织名", profile.CorpName)
}
if profile.UserID != "user_updated" || profile.UserName != "Updated User" || profile.ClientID != "client_updated" {
t.Fatalf("profile metadata was not overwritten: %#v", profile)
}
loaded, err := LoadTokenDataForProfile(configDir, "corp_same")
if err != nil {
t.Fatalf("LoadTokenDataForProfile() error = %v", err)
}
if loaded.AccessToken != "at_second" {
t.Fatalf("access token = %q, want at_second", loaded.AccessToken)
}
}
func TestUpsertProfileFromTokenPromotesCorpIDNameToCorpName(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
first := testToken("at_first", "corp_same", "")
if err := SaveTokenData(configDir, first); err != nil {
t.Fatalf("SaveTokenData(first) error = %v", err)
}
second := testToken("at_second", "corp_same", "新组织名")
if err := SaveTokenData(configDir, second); err != nil {
t.Fatalf("SaveTokenData(second) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("profiles len = %d, want 1: %#v", len(cfg.Profiles), cfg.Profiles)
}
if cfg.Profiles[0].Name != "新组织名" {
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
}
resolved, err := ResolveProfile(configDir, "新组织名")
if err != nil {
t.Fatalf("ResolveProfile(corpName) error = %v", err)
}
if resolved.CorpID != "corp_same" {
t.Fatalf("resolved corpId = %q, want corp_same", resolved.CorpID)
}
}
func TestLoadProfilesPromotesLegacyCorpIDNameToCorpName(t *testing.T) {
configDir := t.TempDir()
raw := `{
"version": 1,
"primaryProfile": "corp_same",
"currentProfile": "corp_same",
"profiles": [
{
"name": "corp_same",
"corpId": "corp_same",
"corpName": "新组织名"
}
]
}`
if err := os.MkdirAll(configDir, 0o700); err != nil {
t.Fatalf("MkdirAll() error = %v", err)
}
if err := os.WriteFile(ProfilesPath(configDir), []byte(raw), 0o600); err != nil {
t.Fatalf("WriteFile(profiles.json) error = %v", err)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if len(cfg.Profiles) != 1 {
t.Fatalf("profiles len = %d, want 1", len(cfg.Profiles))
}
if cfg.Profiles[0].Name != "新组织名" {
t.Fatalf("profile name = %q, want 新组织名", cfg.Profiles[0].Name)
}
}
func TestLegacyKeychainMigrationInitializesProfile(t *testing.T) {
cleanupKeychain(t)
configDir := t.TempDir()
legacy := testToken("at_legacy", "corp_legacy", "Legacy Org")
if err := SaveTokenDataKeychain(legacy); err != nil {
t.Fatalf("SaveTokenDataKeychain() error = %v", err)
}
loaded, err := LoadTokenData(configDir)
if err != nil {
t.Fatalf("LoadTokenData() error = %v", err)
}
if loaded.AccessToken != "at_legacy" {
t.Fatalf("loaded token = %q, want at_legacy", loaded.AccessToken)
}
cfg, err := LoadProfiles(configDir)
if err != nil {
t.Fatalf("LoadProfiles() error = %v", err)
}
if cfg.PrimaryProfile != "corp_legacy" || cfg.CurrentProfile != "corp_legacy" {
t.Fatalf("profile pointers after migration = %#v", cfg)
}
if !TokenDataExistsKeychainForCorpID("corp_legacy") {
t.Fatal("corp-scoped token should exist after migration")
}
}
func TestTokenDataExistsKeychain(t *testing.T) {
cleanupKeychain(t)
@@ -152,6 +420,21 @@ func TestTokenDataExistsKeychain(t *testing.T) {
}
}
func testToken(accessToken, corpID, corpName string) *TokenData {
now := time.Now().UTC()
return &TokenData{
AccessToken: accessToken,
RefreshToken: "rt_" + accessToken,
ExpiresAt: now.Add(2 * time.Hour),
RefreshExpAt: now.Add(30 * 24 * time.Hour),
CorpID: corpID,
CorpName: corpName,
UserID: "user_" + corpID,
UserName: "User " + corpID,
ClientID: "client_" + corpID,
}
}
func TestTokenValidityChecks(t *testing.T) {
t.Parallel()
+7 -1
View File
@@ -18,6 +18,7 @@ package cobracmd
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -43,7 +44,7 @@ func FlagChanged(cmd *cobra.Command, name string) bool {
// NewGroupCommand creates a non-leaf parent command that shows help when invoked.
func NewGroupCommand(use, short string) *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
@@ -53,6 +54,11 @@ func NewGroupCommand(use, short string) *cobra.Command {
return cmd.Help()
},
}
// Tag as a group container: its RunE only prints help, so cobra's
// Runnable() can't distinguish it from a real leaf — callers that need to
// collapse empty groups rely on this annotation.
cmdutil.MarkGroup(cmd)
return cmd
}
// NewHiddenGroupCommand creates a hidden non-leaf parent command.
+85 -1
View File
@@ -51,8 +51,16 @@ var runtimeDefaultWhitelist = map[string]bool{
// detailsByID maps CLI server ID → []DetailTool from the MCP Detail API.
// When provided, tool Short/Long descriptions and typed flags are enriched from Detail API data.
//
// existingTools maps CLI server ID (slug) → set of tool names that server
// actually exposes (from the live tools/list cache). When a server's set is
// present and non-empty, override leaves whose backing MCP tool is missing from
// it are hidden from `--help` (phantom-command guard). When the set is absent or
// empty (cold cache, or a server we have no tools snapshot for) the guard does
// nothing for that server, so an unpopulated cache never blanks the command
// tree. Pass nil to disable the guard entirely.
//
// Conversion rules reference: docs/mcp-to-cli-conversion.md
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool) []*cobra.Command {
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool, existingTools map[string]map[string]struct{}) []*cobra.Command {
type builtCmd struct {
cmd *cobra.Command
parent string // cli.Parent: attach as sub-command of this top-level command
@@ -222,6 +230,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
cmd := NewDirectCommand(route, runner)
// §guard.toolexists: keep `--help` honest under envelope/deployment
// drift. When we know the resolved server's live tool set and it does
// NOT contain this leaf's backing tool, the command is a phantom
// (renders in help but fails at invocation with "tool not found"), so
// hide it. Safety rails:
// - only acts when the set is KNOWN and non-empty (absent/empty =
// unknown = keep; a cold tools cache must never blank the tree);
// - skips pipeline leaves, which orchestrate multiple tools and have
// no single backing toolName to check;
// - Hidden (not removed) so the command stays invocable for anyone
// who calls it directly — it just leaves the help surface.
if len(override.Pipeline) == 0 && existingTools != nil {
if known, ok := existingTools[canonicalProduct]; ok && len(known) > 0 {
if _, exists := known[toolName]; !exists {
cmd.Hidden = true
}
}
}
// Enrich flags with typed parameters from Detail API toolRequest JSON Schema.
if dt, ok := detailIndex[toolName]; ok && dt.ToolRequest != "" {
buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags)
@@ -248,6 +275,11 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
// non-attendance.
installAttendanceHook(cmd, canonicalProduct, toolName)
// §report-hook: native --contents-file / --contents - (stdin)
// resolution + one-of(contents, contents-file) relaxation (see
// report_hooks.go). No-op for non-report.
installReportHook(cmd, canonicalProduct, toolName)
// §1.4: Add to the right parent group
attachToGroup(rootCmd, override.Group, groupCmds, cmd)
}
@@ -314,9 +346,61 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
for _, name := range topOrder {
commands = append(commands, topLevel[name])
}
// §guard.emptygroups: a group whose every leaf is hidden would still show in
// help as an empty heading (e.g. attendance `vacation`/`overtime` once their
// tools are gone). Collapse those. This runs unconditionally because leaves
// get hidden by TWO independent mechanisms — the runtime tool-existence
// guard above AND envelope `hidden:true` overrides — and an envelope-emptied
// group must collapse even when the guard is inert (cold tools cache). It is
// safe regardless of cache state: hideEmptyGroups only ever hides a group all
// of whose children are already hidden; it never hides a leaf, so it cannot
// blank a tree on its own.
for _, c := range commands {
// Collapse empty sub-groups within each product, but never the product
// root itself: a root can legitimately be empty at this point and gain
// visible leaves later from helper/overlay merges, so hiding it here
// could wrongly drop a whole product from `dws --help`.
for _, sub := range c.Commands() {
hideEmptyGroups(sub)
}
}
return commands
}
// hideEmptyGroups recursively hides group commands whose every subcommand is
// hidden — the collateral of the tool-existence guard emptying a group of all
// its leaves. Returns true if cmd is (now) hidden. A command with no
// subcommands is a leaf: its own Hidden flag is returned unchanged. A group is
// only newly hidden when it HAS subcommands and they are ALL hidden, so a group
// retaining at least one visible leaf always stays visible.
func hideEmptyGroups(cmd *cobra.Command) bool {
subs := cmd.Commands()
if len(subs) == 0 {
// No children. A real leaf stands on its own Hidden flag. A group
// container with no children is empty — this happens when every
// override in a group is `hidden:true` (those leaves are never built,
// leaving the group childless) — so hide it. Group containers and leaves
// both have a RunE, so cobra's Runnable() can't tell them apart; the
// group annotation can.
if cmdutil.IsGroup(cmd) {
cmd.Hidden = true
return true
}
return cmd.Hidden
}
allHidden := true
for _, sub := range subs {
if !hideEmptyGroups(sub) {
allHidden = false
}
}
if allHidden {
cmd.Hidden = true
}
return cmd.Hidden
}
// buildDetailIndex creates a map from toolName → DetailTool for fast lookup.
func buildDetailIndex(tools []market.DetailTool) map[string]market.DetailTool {
idx := make(map[string]market.DetailTool, len(tools))
+38 -38
View File
@@ -77,7 +77,7 @@ func TestBuildDynamicCommands_ParentNesting(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
// Should produce only one top-level command: "chat"
if len(cmds) != 1 {
@@ -130,7 +130,7 @@ func TestBuildDynamicCommands_ParentNotFound(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
// Parent not found, should fall back to top-level
if len(cmds) != 1 {
@@ -169,7 +169,7 @@ func TestBuildDynamicCommands_ShorthandFlag(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("expected 1 cmd, got %d", len(cmds))
}
@@ -212,7 +212,7 @@ func TestBuildDynamicCommands_RequiredFlag(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -253,7 +253,7 @@ func TestBuildDynamicCommands_RequiredIgnoredWhenPositional(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -292,7 +292,7 @@ func TestBuildDynamicCommands_PositionalArg(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -347,7 +347,7 @@ func TestBuildDynamicCommands_PositionalArgInjection(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -399,7 +399,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
},
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
article := findChild(cmds[0], "article")
if article == nil {
t.Fatal("article group not found")
@@ -552,7 +552,7 @@ func TestBuildDynamicCommands_PositionalArityMixed(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "do")
if leaf == nil {
t.Fatal("do leaf not found")
@@ -606,7 +606,7 @@ func TestBuildDynamicCommands_ServerOverride(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
leaf := findChild(cmds[0], "bot-list")
if leaf == nil {
t.Fatal("bot-list leaf not found")
@@ -641,7 +641,7 @@ func TestBuildDynamicCommands_ServerOverrideFallback(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
leaf := findChild(cmds[0], "list")
if leaf == nil {
t.Fatal("list leaf not found")
@@ -680,7 +680,7 @@ func TestBuildDynamicCommands_DescriptionOverridesUsage(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -732,7 +732,7 @@ func TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -775,7 +775,7 @@ func TestBuildDynamicCommands_BodyWrapper(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
create := findChild(cmds[0], "create")
if create == nil {
t.Fatal("create leaf not found")
@@ -858,7 +858,7 @@ func TestBuildDynamicCommands_MutuallyExclusive(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
cmds[0].SetArgs([]string{"list", "--group", "g1", "--user", "u1"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -901,7 +901,7 @@ func TestBuildDynamicCommands_RequireOneOf(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
cmds[0].SetArgs([]string{"list"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -940,7 +940,7 @@ func TestBuildDynamicCommands_RequireOneOfSatisfied(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"list", "--group", "g1"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -974,7 +974,7 @@ func TestBuildDynamicCommands_RedirectTo(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
history := findChild(cmds[0], "history")
if history == nil {
t.Fatal("history stub not found")
@@ -1029,7 +1029,7 @@ func TestBuildDynamicCommands_Hints(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
root := cmds[0]
// history hint attached directly under root.
@@ -1094,7 +1094,7 @@ func TestBuildDynamicCommands_UnknownFlagConstraintSkipped(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found (constraint validation must not abort build)")
@@ -1135,7 +1135,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimarySet(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--query", "hello"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1193,7 +1193,7 @@ func TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--keyword", "hi"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1234,7 +1234,7 @@ func TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet(t *testin
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1275,7 +1275,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet(t *testing.
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--query", "primary", "--keyword", "fallback"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1316,7 +1316,7 @@ func TestBuildDynamicCommands_MultipleAliases_MultiAliasChain(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"get", "--user-ids", "u1,u2"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1383,7 +1383,7 @@ func TestBuildDynamicCommands_MultipleAliases_Dedup(t *testing.T) {
// If ApplyBindings panics (duplicate pflag) we fail. Otherwise the cmd
// should build and execute fine.
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--keyword", "ok"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1421,7 +1421,7 @@ func TestBuildDynamicCommands_NoParent(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 2 {
t.Fatalf("expected 2 top-level commands, got %d", len(cmds))
@@ -1457,7 +1457,7 @@ func TestBuildDynamicCommands_ExampleField(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
approval := findChild(cmds[0], "approval")
if approval == nil {
t.Fatal("approval group not found")
@@ -1503,7 +1503,7 @@ func TestApplyBindings_VisibleFlagDefault_String(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "list-forms")
if leaf == nil {
t.Fatal("list-forms leaf not found")
@@ -1548,7 +1548,7 @@ func TestApplyBindings_VisibleFlagDefault_Int(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "list-forms")
if leaf == nil {
t.Fatal("list-forms leaf not found")
@@ -1786,7 +1786,7 @@ func TestBuildDynamicCommands_ParentMergeSameName(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 || cmds[0].Name() != "chat" {
t.Fatalf("expected single top-level 'chat', got %d cmds", len(cmds))
}
@@ -1860,7 +1860,7 @@ func TestBuildDynamicCommands_ParentMergeRecursive(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 || cmds[0].Name() != "chat" {
t.Fatalf("expected single top-level 'chat', got %d", len(cmds))
}
@@ -1934,7 +1934,7 @@ func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("expected 1 top-level, got %d", len(cmds))
}
@@ -1987,7 +1987,7 @@ func TestBuildFlagsFromDetailSchema_FormatEnumAnnotations(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found")
@@ -2052,7 +2052,7 @@ func TestBuildDynamicCommands_MapsTo_WithoutTransform(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "# 标题"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2107,7 +2107,7 @@ func TestBuildDynamicCommands_MapsTo_WithFileReadTransform(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content-file", path})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2157,7 +2157,7 @@ func TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "literal body"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2207,7 +2207,7 @@ func TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "x", "--content-file", "/tmp/y"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
+257
View File
@@ -0,0 +1,257 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
// findLeaf returns the first leaf command with the given Use anywhere under
// root (depth-first), or nil.
func findLeaf(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
if got := findLeaf(c, name); got != nil {
return got
}
}
return nil
}
func toolSet(names ...string) map[string]struct{} {
s := make(map[string]struct{}, len(names))
for _, n := range names {
s[n] = struct{}{}
}
return s
}
// attendanceLike builds one server with a real tool and a phantom tool, the
// exact shape of the production drift (e.g. attendance: only a handful of the
// declared overrides map to deployed tools).
func attendanceLike() []market.ServerDescriptor {
return []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
ToolOverrides: map[string]market.CLIToolOverride{
"get_attendance_summary": {CLIName: "summary"}, // real
"get_overtime_rule": {CLIName: "overtime"}, // phantom
},
},
},
}
}
// TestPhantomGuard_HidesWhenToolSetKnown is the core behaviour: when the live
// tool set is known and non-empty, a leaf whose backing tool is absent is
// hidden from --help while the real leaf stays visible.
func TestPhantomGuard_HidesWhenToolSetKnown(t *testing.T) {
t.Parallel()
existing := map[string]map[string]struct{}{
"attendance": toolSet("get_attendance_summary"), // overtime is NOT deployed
}
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, existing)
summary := findLeaf(cmds[0], "summary")
overtime := findLeaf(cmds[0], "overtime")
if summary == nil || overtime == nil {
t.Fatalf("both leaves should still be registered (invocable); summary=%v overtime=%v", summary, overtime)
}
if summary.Hidden {
t.Error("real command 'summary' must stay visible in --help")
}
if !overtime.Hidden {
t.Error("phantom command 'overtime' must be hidden from --help")
}
}
// TestPhantomGuard_ColdCacheKeepsEverything is the safety rail that the prior
// (source-blind) plan got wrong: with no tool set available (nil map), the
// guard must do nothing — never blank the command tree on a cold cache.
func TestPhantomGuard_ColdCacheKeepsEverything(t *testing.T) {
t.Parallel()
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, nil)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("cold cache (nil existingTools) must not hide %q", name)
}
}
}
// TestPhantomGuard_EmptyOrAbsentSetKeepsEverything: an empty set for a server,
// or a server missing from the map entirely, both mean "unknown" — keep all.
func TestPhantomGuard_EmptyOrAbsentSetKeepsEverything(t *testing.T) {
t.Parallel()
cases := []struct {
name string
existing map[string]map[string]struct{}
}{
{"empty set for server", map[string]map[string]struct{}{"attendance": {}}},
{"server absent from map", map[string]map[string]struct{}{"someother": toolSet("x")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, tc.existing)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("%s: must not hide %q when tool set is unknown", tc.name, name)
}
}
})
}
}
// TestPhantomGuard_ServerOverrideRoutesToTargetSet: a leaf with serverOverride
// must be checked against the TARGET server's tool set, not the host's. This is
// what prevents false-flagging legit cross-server routes (contact→hrmregister,
// doc→doc-comment).
func TestPhantomGuard_ServerOverrideRoutesToTargetSet(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-contact",
CLI: market.CLIOverlay{
ID: "contact",
Command: "contact",
ToolOverrides: map[string]market.CLIToolOverride{
// routed to hrmregister; the tool lives there, not in contact
"get_roster": {CLIName: "roster", ServerOverride: "hrmregister"},
},
},
},
}
// contact's own set is empty of get_roster, but hrmregister has it.
existing := map[string]map[string]struct{}{
"contact": toolSet("search_user"),
"hrmregister": toolSet("get_roster"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
roster := findLeaf(cmds[0], "roster")
if roster == nil {
t.Fatal("roster leaf should be registered")
}
if roster.Hidden {
t.Error("serverOverride leaf must resolve against the target server's set and stay visible")
}
}
// TestPhantomGuard_EmptyGroupCollapses: a group all of whose overrides are
// hidden:true (so none of its leaves are built) must itself be hidden from
// help, while a group keeping at least one visible leaf stays. This runs
// regardless of the tools-cache oracle (envelope hidden:true is cache-
// independent), so existingTools is nil here.
func TestPhantomGuard_EmptyGroupCollapses(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
Groups: map[string]market.CLIGroupDef{
"vacation": {Description: "假期管理"}, // all leaves hidden -> collapse
"record": {Description: "考勤记录"}, // keeps a visible leaf
},
ToolOverrides: map[string]market.CLIToolOverride{
"get_leave_types": {CLIName: "types", Group: "vacation", Hidden: true},
"get_leave_balance_quota": {CLIName: "balance", Group: "vacation", Hidden: true},
"get_user_attendance_record": {CLIName: "get", Group: "record"},
},
},
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
vacation := findGroup(cmds[0], "vacation")
record := findGroup(cmds[0], "record")
if vacation == nil || record == nil {
t.Fatalf("both groups should exist as commands; vacation=%v record=%v", vacation, record)
}
if !vacation.Hidden {
t.Error("group 'vacation' with only hidden leaves must collapse (be hidden)")
}
if record.Hidden {
t.Error("group 'record' with a visible leaf must stay visible")
}
}
// findGroup returns a direct child of root with the given name (groups attach
// directly under the product root).
func findGroup(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
// TestPhantomGuard_PipelineLeafNeverHidden: pipeline leaves orchestrate multiple
// tools and have no single backing toolName, so the guard must skip them even
// when the override key is not a deployed tool.
func TestPhantomGuard_PipelineLeafNeverHidden(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-im",
CLI: market.CLIOverlay{
ID: "im",
Command: "im",
ToolOverrides: map[string]market.CLIToolOverride{
"download_media": {
CLIName: "download-media",
Pipeline: []market.PipelineStep{
{Tool: "get_resource_download_url"},
},
},
},
},
},
}
// download_media itself is not a deployed tool name, but the pipeline is.
existing := map[string]map[string]struct{}{
"im": toolSet("get_resource_download_url"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
dl := findLeaf(cmds[0], "download-media")
if dl == nil {
t.Fatal("download-media leaf should be registered")
}
if dl.Hidden {
t.Error("pipeline leaf must never be hidden by the tool-existence guard")
}
}
+1 -1
View File
@@ -751,7 +751,7 @@ func collectSchemaFlags(cmd *cobra.Command, bindings []FlagBinding, params map[s
"json": true, "params": true, "help": true,
"format": true, "fields": true, "jq": true,
"debug": true, "verbose": true, "dry-run": true,
"yes": true, "mock": true, "timeout": true,
"yes": true, "mock": true, "profile": true, "timeout": true,
"client-id": true, "client-secret": true,
}
+2 -2
View File
@@ -83,7 +83,7 @@ func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
}
// Must not panic; the command must build and stay executable.
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
}
@@ -120,7 +120,7 @@ func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
boom, _, err := cmds[0].Find([]string{"boom"})
if err != nil {
t.Fatalf("find boom: %v", err)
+3 -1
View File
@@ -288,6 +288,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
cmd.Flags().Bool("verbose", false, "Verbose")
cmd.Flags().Bool("dry-run", false, "Dry run")
cmd.Flags().String("format", "json", "Format")
cmd.Flags().String("profile", "", "Profile")
cmd.Flags().String("json", "", "")
cmd.Flags().String("params", "", "")
@@ -296,6 +297,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
_ = cmd.Flags().Set("verbose", "true")
_ = cmd.Flags().Set("dry-run", "true")
_ = cmd.Flags().Set("format", "table")
_ = cmd.Flags().Set("profile", "corp_profile")
params := make(map[string]any)
collectSchemaFlags(cmd, nil, params)
@@ -304,7 +306,7 @@ func TestCollectSchemaFlagsSkipsGlobalFlags(t *testing.T) {
t.Errorf("name = %v, want Bob", params["name"])
}
// Global flags should be skipped
for _, skip := range []string{"debug", "verbose", "dry_run", "format"} {
for _, skip := range []string{"debug", "verbose", "dry_run", "format", "profile"} {
if _, exists := params[skip]; exists {
t.Errorf("%s should be skipped (global flag)", skip)
}
+186
View File
@@ -0,0 +1,186 @@
// 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.
// report_hooks.go — CLI-side input resolution for the `report` product.
//
// The envelope publishes `report entry submit` (MCP tool create_report) with a
// `--contents` flag (json_parse, required) and a sibling `--contents-file`
// flag (omitWhen empty, no transform/mapsTo). On its own, `--contents-file`
// therefore goes nowhere: its value maps to the unused `contentsFile` param and
// the real `contents` param stays empty, so a `--contents-file`-only (or
// `--contents -` stdin) submit silently sends `contents: [null]` and the report
// fails. The literal-only `--contents` path works, which is why
// `report create` (the helper, inline-only) succeeds while
// `report entry submit --contents-file` does not.
//
// The wukong reference implementation reads the file/stdin natively inside its
// hand-written cobra RunE (dws-wukong/wukong/products/report.go
// resolveReportContentsFromFlags, priority: --contents-file > --contents -
// (stdin) > --contents '<json>'). The open-source CLI is envelope-driven, so we
// attach the equivalent native resolution as a build-time hook here, mirroring
// AttachReportListReadableEnrichment (which layers wukong-equivalent list
// enrichment onto the same envelope leaves). No discovery-config change is
// needed: the hook populates the real `--contents` flag before the envelope's
// json_parse transform runs, and the broken `contentsFile` override is left
// inert.
//
// Two build-time adjustments make `--contents-file`-only valid:
//
// 1. The envelope marks `--contents` individually required (cobra
// MarkFlagRequired, enforced at parse time, before PreRunE). We clear that
// annotation and instead declare a `contents` / `contents-file` one-of
// group (MarkFlagsOneRequired, validated by ValidateFlagGroups — also
// before PreRunE, but satisfied when either flag is set). Supplying
// neither still errors, now naming both flags.
// 2. A chained PreRunE resolves the chosen source into `--contents` so the
// downstream json_parse transform sees inline JSON regardless of origin.
package compat
import (
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// reportContentsMaxBytes caps the contents payload at 10MB, matching the
// wukong upstream limit (dws-wukong/wukong/products/report.go
// reportContentsMaxBytes). Oversized input is rejected rather than truncated.
const reportContentsMaxBytes = 10 * 1024 * 1024
// reportToolsWithContentsFile lists every report toolName whose `--contents` /
// `--contents-file` pair needs native file/stdin resolution. Today only
// create_report (the `report entry submit` leaf) carries the pair.
var reportToolsWithContentsFile = map[string]bool{
"create_report": true,
}
// installReportHook wires report-specific input resolution onto leaf commands
// emitted by BuildDynamicCommands. It is a no-op for non-report products and
// for report tools that do not expose the contents/contents-file pair.
//
// The hook chain preserves the cmd.PreRunE that NewDirectCommand already
// installed (currently validateRequireTogether) by invoking it first.
func installReportHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil {
return
}
if strings.TrimSpace(canonicalProduct) != "report" {
return
}
if !reportToolsWithContentsFile[toolName] {
return
}
contents := cmd.Flags().Lookup("contents")
file := cmd.Flags().Lookup("contents-file")
if contents == nil || file == nil {
// Envelope shape changed (renamed/removed flags) — do not block the
// command; leave whatever the envelope declared untouched.
return
}
// (1) Relax the individually-required `--contents` into a one-of group so
// `--contents-file`-only (or `--contents -`) is accepted. Clearing the
// required annotation must happen before parse-time ValidateRequiredFlags;
// this hook runs at build time, so it does.
if contents.Annotations != nil {
delete(contents.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.MarkFlagsOneRequired("contents", "contents-file")
// (2) Resolve the chosen source into --contents before the RunE transform.
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return resolveReportContents(c)
}
}
// resolveReportContents applies the wukong source priority — `--contents-file`
// (file) > `--contents -` (stdin) > `--contents '<json>'` (literal) — and
// writes the resolved JSON string back into the `--contents` flag so the
// downstream json_parse transform decodes it uniformly. When a file or stdin
// source is used, `--contents-file` is cleared so the envelope's omitWhen:empty
// drops the now-redundant param.
func resolveReportContents(cmd *cobra.Command) error {
filePath, _ := cmd.Flags().GetString("contents-file")
if strings.TrimSpace(filePath) != "" {
data, err := readReportContentsFile(filePath)
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from --contents-file")
}
_ = cmd.Flags().Set("contents-file", "")
return nil
}
raw, _ := cmd.Flags().GetString("contents")
if strings.TrimSpace(raw) == "-" {
data, err := readReportContentsLimited(cmd.InOrStdin(), "--contents -")
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from stdin")
}
}
return nil
}
// readReportContentsFile opens a file path and reads its contents under the
// 10MB cap and UTF-8 check. Error wording mirrors wukong so agents and humans
// see a stable message across both editions.
func readReportContentsFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return "", apperrors.NewValidation(
fmt.Sprintf("--contents-file: file not found: %s", path),
apperrors.WithHint("确认路径存在,且指向一个 JSON 文件"),
)
}
return "", apperrors.NewValidation(fmt.Sprintf("--contents-file: cannot read %s: %v", path, err))
}
defer file.Close()
return readReportContentsLimited(file, fmt.Sprintf("--contents-file %s", path))
}
// readReportContentsLimited reads from r enforcing the 10MB cap and UTF-8
// validity. A LimitReader at cap+1 detects overflow without reading unbounded.
func readReportContentsLimited(r io.Reader, source string) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, int64(reportContentsMaxBytes)+1))
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("%s: read failed: %v", source, err))
}
if len(data) > reportContentsMaxBytes {
return "", apperrors.NewValidation(
fmt.Sprintf("%s: contents exceed maximum size of 10MB", source),
apperrors.WithHint("精简内容或拆分为多份日志提交"),
)
}
if !utf8.Valid(data) {
return "", apperrors.NewValidation(fmt.Sprintf("%s: not valid UTF-8", source))
}
return string(data), nil
}
+198
View File
@@ -0,0 +1,198 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newReportSubmitStub mirrors the leaf command shape emitted by
// BuildDynamicCommands for `report entry submit` (envelope: create_report).
// Only the flags the hook touches are registered. --contents is marked
// required to reproduce the envelope's MarkFlagRequired so the relaxation
// behaviour can be asserted.
func newReportSubmitStub() *cobra.Command {
cmd := &cobra.Command{Use: "submit", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("contents", "", "contents JSON array")
cmd.Flags().String("contents-file", "", "contents JSON file")
cmd.Flags().String("template-id", "", "template id")
_ = cmd.MarkFlagRequired("contents")
return cmd
}
const reportContentsPayload = `[{"key":"今日完成工作","sort":"0","content":"done","contentType":"markdown","type":"1"}]`
func TestResolveReportContents_FromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(file): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from file: %q", got)
}
// contents-file must be cleared so omitWhen:empty drops the dead param.
if cf, _ := cmd.Flags().GetString("contents-file"); cf != "" {
t.Fatalf("--contents-file should be cleared after resolution, got %q", cf)
}
}
func TestResolveReportContents_FromStdin(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", "-"); err != nil {
t.Fatal(err)
}
cmd.SetIn(strings.NewReader(reportContentsPayload))
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(stdin): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from stdin: %q", got)
}
}
func TestResolveReportContents_InlineUntouched(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(inline): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("inline --contents must be left untouched, got %q", got)
}
}
func TestResolveReportContents_FilePriorityOverInline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatal(err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", `[{"stale":"inline"}]`); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents: %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents-file must win over inline --contents, got %q", got)
}
}
func TestResolveReportContents_MissingFileErrors(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", filepath.Join(t.TempDir(), "nope.json")); err != nil {
t.Fatal(err)
}
err := resolveReportContents(cmd)
if err == nil {
t.Fatal("expected error for missing --contents-file path")
}
if !strings.Contains(err.Error(), "file not found") {
t.Fatalf("unexpected error: %v", err)
}
}
// ── installReportHook composition ──────────────────────────────
func TestInstallReportHook_RelaxesRequiredToOneOf(t *testing.T) {
cmd := newReportSubmitStub()
// Before the hook, --contents carries the cobra required annotation.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("precondition: --contents should start out required")
}
installReportHook(cmd, "report", "create_report")
// After the hook, the individual required annotation must be cleared so a
// --contents-file-only invocation is not rejected at parse time.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] != nil {
t.Fatal("installReportHook should clear the individual required on --contents")
}
// And a PreRunE must now be installed to resolve the source.
if cmd.PreRunE == nil {
t.Fatal("installReportHook should install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherProduct(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "chat", "create_report")
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("non-report product must not touch required annotation")
}
if cmd.PreRunE != nil {
t.Fatal("non-report product must not install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherReportTool(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "report", "get_received_report_list")
if cmd.PreRunE != nil {
t.Fatal("non-target report tool must not install a PreRunE")
}
}
func TestInstallReportHook_ChainsExistingPreRunE(t *testing.T) {
cmd := newReportSubmitStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
installReportHook(cmd, "report", "create_report")
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE was dropped")
}
}
func TestInstallReportHook_BailsIfChainedPreRunEFails(t *testing.T) {
cmd := newReportSubmitStub()
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
installReportHook(cmd, "report", "create_report")
err := cmd.PreRunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "original boom") {
t.Fatalf("expected original PreRunE error to bubble, got %v", err)
}
}
func TestInstallReportHook_NilCmdSafe(t *testing.T) {
installReportHook(nil, "report", "create_report")
}
+45 -2
View File
@@ -243,6 +243,18 @@ func transformJSONParse(value any) (any, error) {
if s == "" {
return value, nil
}
// @file / @- expansion — read the JSON/YAML payload from a file or stdin
// before parsing. A leading "@" is an unambiguous file sentinel because a
// JSON/YAML value never starts with "@"; this is what the error hint below
// promises and lets long/complex payloads (many records, big cell ranges)
// avoid shell-quoting hell.
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
// Strict JSON first — fast path and unambiguous type promotion (numbers
// stay numbers, etc.).
var parsed any
@@ -257,10 +269,32 @@ func transformJSONParse(value any) (any, error) {
return nil, apperrors.NewValidation(
"json_parse: input is not valid JSON or YAML; " +
"quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " +
"or pass `@path/to/file.json` to read from a file",
"or pass `@path/to/file.json` (or `@-` for stdin) to read from a file",
)
}
// resolveJSONSource expands an @file / @- reference used by the json_parse
// transforms. A leading "@" is the file sentinel: "@-" reads stdin, "@<path>"
// reads the file (UTF-8, via transformFileRead). Any value not starting with
// "@" is returned unchanged. JSON/YAML payloads never start with "@", so this
// is unambiguous for structured flags.
func resolveJSONSource(s string) (string, error) {
if !strings.HasPrefix(s, "@") {
return s, nil
}
ref := strings.TrimSpace(s[1:])
if ref == "" {
return "", apperrors.NewValidation(
"json_parse: `@` must be followed by a file path, or `@-` to read from stdin")
}
out, err := transformFileRead(ref)
if err != nil {
return "", err
}
loaded, _ := out.(string)
return strings.TrimSpace(loaded), nil
}
// transformJSONParseStrict is the strict variant of json_parse: only accepts
// well-formed JSON, rejecting input that the YAML fallback would otherwise
// silently coerce to a scalar string. Use when the upstream tool requires a
@@ -274,12 +308,21 @@ func transformJSONParseStrict(value any) (any, error) {
if s == "" {
return value, nil
}
// @file / @- expansion — same sentinel as json_parse (see resolveJSONSource).
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return nil, apperrors.NewValidation(
"json_parse_strict: input is not valid JSON; " +
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
"as strict JSON (e.g. '[{\"key\":\"value\"}]') or use `json_parse` for YAML-tolerant parsing",
"as strict JSON (e.g. '[{\"key\":\"value\"}]'), pass `@path/to/file.json` " +
"(or `@-` for stdin), or use `json_parse` for YAML-tolerant parsing",
)
}
return parsed, nil
+59
View File
@@ -127,6 +127,65 @@ func TestJSONParse_InvalidInput(t *testing.T) {
}
}
func TestJSONParse_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "payload.json")
if err := os.WriteFile(path, []byte(`[{"k":"长内容\n多行","n":1}]`), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse", nil)
if err != nil {
t.Fatalf("json_parse @file: %v", err)
}
arr, ok := got.([]any)
if !ok || len(arr) != 1 {
t.Fatalf("expected 1-element array from @file, got %T %v", got, got)
}
item := arr[0].(map[string]any)
if item["k"] != "长内容\n多行" {
t.Fatalf("@file content mismatch: %v", item)
}
}
func TestJSONParse_AtFileMissing(t *testing.T) {
if _, err := ApplyTransform("@"+filepath.Join(t.TempDir(), "nope.json"), "json_parse", nil); err == nil {
t.Fatal("json_parse @missing-file should error")
}
}
func TestJSONParse_BareAtErrors(t *testing.T) {
_, err := ApplyTransform("@", "json_parse", nil)
if err == nil {
t.Fatal("bare @ should error (needs a path or -)")
}
}
func TestJSONParseStrict_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "p.json")
if err := os.WriteFile(path, []byte(`{"a":[1,2,3]}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse_strict", nil)
if err != nil {
t.Fatalf("json_parse_strict @file: %v", err)
}
if _, ok := got.(map[string]any); !ok {
t.Fatalf("expected object, got %T", got)
}
}
func TestJSONParse_AtPassthroughNonAt(t *testing.T) {
// A value not starting with "@" must be parsed inline, untouched.
got, err := ApplyTransform(`[{"x":1}]`, "json_parse", nil)
if err != nil {
t.Fatalf("inline json_parse: %v", err)
}
if arr, ok := got.([]any); !ok || len(arr) != 1 {
t.Fatalf("inline parse regressed: %T %v", got, got)
}
}
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
// read in full and surfaced as a string value. This is the contract the
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
+6 -5
View File
@@ -334,13 +334,14 @@ func newChatMessageSendCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("file-type", "", "文件类型/扩展名 (msg-type=file)")
cmd.Flags().String("file-path", "", "文件展示路径 (msg-type=file)")
cmd.Flags().Int64("file-size", 0, "文件大小,单位字节 (msg-type=file)")
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才在消息下方显示 AI 发送角标)")
cmd.Flags().Bool("ai-tag", true, "标记为「通过AI发送」角标,默认带上(透明标识 AI/CLI 代发);仅当 --ai-tag=false 时不带角标(按本人发送)")
return cmd
}
// attachAITag 仅在用户显式传入 --ai-tag 时,给发送参数加上 clawType,
// 由 IM 服务端据此渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。
// 默认不带:是否标记 AI 发送交由用户自行选择,不强加。
// attachAITag 在 --ai-tag 为真时给发送参数加上 clawType,由 IM 服务端据此
// 渲染「通过AI发送」角标 (悟空版渲染「悟空AI发送」)。--ai-tag 默认 true:
// 经 dws/agent 代发的消息默认带角标以透明标识 AI/CLI 代发,仅当用户显式
// 传 --ai-tag=false 时才不带 (按本人发送)。
func attachAITag(cmd *cobra.Command, params map[string]any) {
if on, _ := cmd.Flags().GetBool("ai-tag"); on {
params["clawType"] = edition.ClawType()
@@ -1017,7 +1018,7 @@ func newChatMessageReplyCommand(runner executor.Runner) *cobra.Command {
cmd.Flags().String("ref-sender", "", "被引用消息发送者 openDingTalkId (必填)")
cmd.Flags().String("text", "", "回复正文 (必填)")
cmd.Flags().String("uuid", "", "可选 uuid(幂等标识)")
cmd.Flags().Bool("ai-tag", false, "标记为「通过AI发送」(默认不带;仅传 --ai-tag 时才显示 AI 发送角标)")
cmd.Flags().Bool("ai-tag", true, "标记为「通过AI发送」角标,默认带上(透明标识 AI/CLI 代发);仅当 --ai-tag=false 时不带角标(按本人发送)")
return cmd
}
+24 -5
View File
@@ -354,8 +354,8 @@ func TestChatMessageAITagControlsClawType(t *testing.T) {
},
}
for _, tc := range cases {
// Default: no --ai-tag → must omit clawType entirely (no badge).
t.Run(tc.name+"/default-no-tag", func(t *testing.T) {
// Default: no --ai-tag → ai-tag defaults to true → must attach clawType.
t.Run(tc.name+"/default-has-tag", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(runner)
var out bytes.Buffer
@@ -365,11 +365,30 @@ func TestChatMessageAITagControlsClawType(t *testing.T) {
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if v, ok := runner.last.Params["clawType"]; ok {
t.Fatalf("default send must omit clawType, got %#v", v)
got, ok := runner.last.Params["clawType"]
if !ok {
t.Fatalf("default send must attach clawType (ai-tag defaults true); got %#v", runner.last.Params)
}
if got != edition.DefaultOSSClawType {
t.Fatalf("clawType = %#v, want %q", got, edition.DefaultOSSClawType)
}
})
// Opt-in: --ai-tag → attach the edition claw identity.
// Opt-out: --ai-tag=false → omit clawType entirely (no badge).
t.Run(tc.name+"/ai-tag-false", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(runner)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(append(append([]string{}, tc.args...), "--ai-tag=false"))
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v\noutput:\n%s", err, out.String())
}
if v, ok := runner.last.Params["clawType"]; ok {
t.Fatalf("--ai-tag=false must omit clawType, got %#v", v)
}
})
// Opt-in (explicit): --ai-tag → attach the edition claw identity.
t.Run(tc.name+"/with-ai-tag", func(t *testing.T) {
runner := &captureRunner{}
cmd := tc.make(runner)
+125 -13
View File
@@ -25,6 +25,7 @@ import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
@@ -316,6 +317,10 @@ func newSheetRangeReadCommand(runner executor.Runner) *cobra.Command {
// 使"清除后回读不再含该 key"成立(与 wukong 对齐)。dry-run 不投影。
if !commandDryRun(cmd) {
sheetCleanCellInfos(result.Response)
// Project a flat `values` 2D array alongside the rich `cells` so
// consumers expecting wukong's get_range shape (values: [["a"]])
// work while `cells` (styles/richText) stays available.
sheetProjectFlatValues(result.Response)
}
return writeCommandPayload(cmd, result)
})
@@ -329,22 +334,55 @@ func newSheetRangeReadCommand(runner executor.Runner) *cobra.Command {
func newSheetRangeUpdateCommand(runner executor.Runner) *cobra.Command {
cmd := newSheetLeaf("update", "更新工作表指定区域内容", func(cmd *cobra.Command, _ []string) error {
if err := sheetValidateRequired(cmd, "node", "sheet-id", "range", "values"); err != nil {
if err := sheetValidateRequired(cmd, "node", "sheet-id", "range"); err != nil {
return err
}
hasValues := sheetStringFlag(cmd, "values") != ""
hasHyperlinks := sheetStringFlag(cmd, "hyperlinks") != ""
if !hasValues && !hasHyperlinks {
return apperrors.NewValidation("--values 或 --hyperlinks 至少传入一项")
}
var cells [][]any
if err := sheetParseJSONFlag(cmd, "values", &cells); err != nil {
return err
if hasValues {
if err := sheetParseJSONFlag(cmd, "values", &cells); err != nil {
return err
}
}
// --hyperlinks overlays cell-level links onto the same grid (matches
// wukong update_range: hyperlinks coexist with values, hyperlinks win).
// Each item is wukong's {type:path,link,text}, which is exactly open's
// cell `hyperlink` field shape.
if hasHyperlinks {
var hyperlinks [][]any
if err := sheetParseJSONFlag(cmd, "hyperlinks", &hyperlinks); err != nil {
return err
}
merged, err := sheetMergeHyperlinks(cells, hyperlinks)
if err != nil {
return err
}
cells = merged
}
for i, row := range cells {
for j, cell := range row {
path := fmt.Sprintf("--values[%d][%d]", i, j)
if cell == nil {
return apperrors.NewValidation(fmt.Sprintf("%s: 不支持 null,每个单元格必须是 {type:text,...}、{type:richText,...}、{hyperlink:...} 对象,或 {} 表示保留原值", path))
// null clears the cell content (matches wukong's update_range
// pass-through). Use {} (keep-original) for "leave unchanged".
cells[i][j] = map[string]any{"type": "text", "text": ""}
continue
}
// Auto-wrap a scalar cell (string / number / bool) into a text
// cell so the simple form --values '[["姓名","部门"]]' works, the
// same shape `append` and wukong's update_range accept. Explicit
// object cells fall through to the rich validation below.
if wrapped, ok := sheetWrapScalarCell(cell); ok {
cells[i][j] = wrapped
continue
}
cellMap, ok := cell.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 必须为 object(如 {\"type\":\"text\",\"text\":\"...\"})", path))
return apperrors.NewValidation(fmt.Sprintf("%s: 必须为 object(如 {\"type\":\"text\",\"text\":\"...\"})、纯字符串/数字,或 {} 表示保留原值", path))
}
// {} 空对象 = 跳过该单元格,保留原值不变
if len(cellMap) == 0 {
@@ -364,10 +402,59 @@ func newSheetRangeUpdateCommand(runner executor.Runner) *cobra.Command {
})
addSheetBaseFlags(cmd)
cmd.Flags().String("range", "", "目标单元格区域地址 (必填)")
cmd.Flags().String("values", "", "单元格内容二维 JSON 数组 (必填)")
cmd.Flags().String("values", "", "单元格内容二维 JSON 数组 (--values 或 --hyperlinks 至少一项)")
cmd.Flags().String("hyperlinks", "", "超链接二维 JSON 数组,每项 {type:path,link,text};与 --values 共存时按位叠加")
return cmd
}
// sheetMergeHyperlinks overlays a hyperlinks grid onto a values cell grid so
// `sheet range update` accepts the separate --hyperlinks flag (wukong shape).
// Each non-null hyperlink item is wukong's {type:path,link,text}, attached as
// the cell's `hyperlink` field; a scalar value cell is first wrapped to a text
// cell so the link has a host cell. Returns a grid sized to the larger of the
// two inputs.
func sheetMergeHyperlinks(cells [][]any, hyperlinks [][]any) ([][]any, error) {
rows := len(cells)
if len(hyperlinks) > rows {
rows = len(hyperlinks)
}
out := make([][]any, rows)
for i := 0; i < rows; i++ {
var valRow, linkRow []any
if i < len(cells) {
valRow = cells[i]
}
if i < len(hyperlinks) {
linkRow = hyperlinks[i]
}
cols := len(valRow)
if len(linkRow) > cols {
cols = len(linkRow)
}
row := make([]any, cols)
for j := 0; j < cols; j++ {
if j < len(valRow) {
row[j] = valRow[j]
}
if j < len(linkRow) && linkRow[j] != nil {
cell, ok := row[j].(map[string]any)
if !ok {
// No (or scalar) value cell here — host the link on a cell.
if wrapped, wok := sheetWrapScalarCell(row[j]); wok {
cell = wrapped
} else {
cell = map[string]any{}
}
}
cell["hyperlink"] = linkRow[j]
row[j] = cell
}
}
out[i] = row
}
return out, nil
}
func newSheetRangeClearCommand(runner executor.Runner) *cobra.Command {
cmd := newSheetLeaf("clear", "清除工作表指定区域", func(cmd *cobra.Command, _ []string) error {
if err := sheetValidateRequired(cmd, "node", "sheet-id", "range"); err != nil {
@@ -840,10 +927,10 @@ func newSheetFilterCreateCommand(runner executor.Runner) *cobra.Command {
"sheetId": sheetStringFlag(cmd, "sheet-id"),
"range": sheetStringFlag(cmd, "range"),
}
if v := sheetStringFlag(cmd, "criteria"); v != "" {
if sheetStringFlag(cmd, "criteria") != "" {
var criteria []any
if err := json.Unmarshal([]byte(v), &criteria); err != nil {
return fmt.Errorf("--criteria JSON parse failed: %w", err)
if err := sheetParseJSONFlag(cmd, "criteria", &criteria); err != nil {
return err
}
params["criteria"] = criteria
}
@@ -912,10 +999,10 @@ func newSheetFilterViewCreateCommand(runner executor.Runner) *cobra.Command {
"name": sheetStringFlag(cmd, "name"),
"range": sheetStringFlag(cmd, "range"),
}
if v := sheetStringFlag(cmd, "criteria"); v != "" {
if sheetStringFlag(cmd, "criteria") != "" {
var criteria []any
if err := json.Unmarshal([]byte(v), &criteria); err != nil {
return fmt.Errorf("--criteria JSON parse failed: %w", err)
if err := sheetParseJSONFlag(cmd, "criteria", &criteria); err != nil {
return err
}
params["criteria"] = criteria
}
@@ -1577,8 +1664,33 @@ func sheetAddChangedIntParam(cmd *cobra.Command, params map[string]any, key, fla
}
}
// sheetWrapScalarCell converts a bare scalar cell value (string / number /
// bool) into a {type:text,text:"..."} cell object accepted by set_cell_range.
// Returns ok=false for non-scalar values (maps, slices) so the caller can apply
// its richer object validation. This lets `sheet range update` accept the same
// plain `[["a","b"]]` shape as `sheet append` and the wukong update_range tool.
func sheetWrapScalarCell(cell any) (map[string]any, bool) {
switch v := cell.(type) {
case string:
return map[string]any{"type": "text", "text": v}, true
case float64:
return map[string]any{"type": "text", "text": strconv.FormatFloat(v, 'f', -1, 64)}, true
case json.Number:
return map[string]any{"type": "text", "text": v.String()}, true
case bool:
return map[string]any{"type": "text", "text": strconv.FormatBool(v)}, true
}
return nil, false
}
func sheetParseJSONFlag(cmd *cobra.Command, flag string, out any) error {
raw := sheetStringFlag(cmd, flag)
// Resolve @file / @- so large 2D ranges, criteria and sort-keys can come
// from a file or stdin instead of inline shell-quoted JSON. ResolveInputSource
// passes values not starting with "@" through unchanged.
raw, err := cli.ResolveInputSource(sheetStringFlag(cmd, flag), flag, cli.NewStdinGuard())
if err != nil {
return err
}
if err := json.Unmarshal([]byte(raw), out); err != nil {
return fmt.Errorf("--%s JSON parse failed: %w", flag, err)
}
+49
View File
@@ -216,6 +216,55 @@ func sheetCleanCellInfos(v any) {
}
}
// sheetProjectFlatValues walks an MCP get_cell_infos response and, next to any
// rich `cells` array, adds a flat `values` 2D array of each cell's scalar
// `value`. This gives consumers the simple wukong get_range shape
// (values: [["姓名","部门"]]) without dropping the rich `cells` payload.
// Idempotent: skips when `values` already exists or there are no cells.
func sheetProjectFlatValues(v any) {
switch t := v.(type) {
case map[string]any:
if cellsRaw, ok := t["cells"]; ok {
if _, exists := t["values"]; !exists {
if flat := sheetFlatValuesFromCells(cellsRaw); flat != nil {
t["values"] = flat
}
}
}
for _, child := range t {
sheetProjectFlatValues(child)
}
case []any:
for _, child := range t {
sheetProjectFlatValues(child)
}
}
}
func sheetFlatValuesFromCells(cellsRaw any) []any {
rows, ok := cellsRaw.([]any)
if !ok {
return nil
}
out := make([]any, 0, len(rows))
for _, row := range rows {
rowSlice, ok := row.([]any)
if !ok {
return nil
}
flatRow := make([]any, 0, len(rowSlice))
for _, cell := range rowSlice {
if cellMap, ok := cell.(map[string]any); ok {
flatRow = append(flatRow, cellMap["value"])
} else {
flatRow = append(flatRow, cell)
}
}
out = append(out, flatRow)
}
return out
}
func sheetStripCellsMeta(cellsRaw any) {
rows, ok := cellsRaw.([]any)
if !ok {
+6 -6
View File
@@ -1907,13 +1907,13 @@ func TestChmod_agentCode_env_invalid(t *testing.T) {
// wins and env is silently ignored (no warning needed because the flag is
// the explicit, scripted intent).
func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
t.Setenv(agentCodeEnv, "envval")
t.Setenv(agentCodeEnv, "qoder")
fake := &fakeToolCaller{resultOK: true}
cmd := buildChmod(t, fake)
_ = cmd.Flags().Set("grant-type", "once")
_ = cmd.Flags().Set("agentCode", "flagval")
_ = cmd.Flags().Set("agentCode", "QoderWork")
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
t.Fatalf("chmod RunE error = %v", err)
@@ -1921,11 +1921,11 @@ func TestChmod_agentCode_flag_wins_over_env(t *testing.T) {
if fake.gotTool != patBatchGrantToolName {
t.Fatalf("gotTool = %q, want %q", fake.gotTool, patBatchGrantToolName)
}
if got := fake.gotAgentEnv; got != "flagval" {
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "flagval")
if got := fake.gotAgentEnv; got != "QoderWork" {
t.Fatalf("agent env = %q, want %q (flag must win over env)", got, "QoderWork")
}
if got := fake.gotArgs["agentCode"]; got != "flagval" {
t.Fatalf("batch agentCode = %#v, want flagval", got)
if got := fake.gotArgs["agentCode"]; got != "QoderWork" {
t.Fatalf("batch agentCode = %#v, want QoderWork", got)
}
}
+27
View File
@@ -52,3 +52,30 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool {
}
return cmd.Annotations[SourceAnnotation] == SourceEnvelope
}
// KindAnnotation records the structural role of a command. It distinguishes a
// pure group container (a heading whose RunE only prints help) from a runnable
// leaf. Both have a RunE set, so cobra's Runnable() cannot tell them apart —
// this annotation can. Kept here next to SourceAnnotation so the literals stay
// in one place.
const KindAnnotation = "dws.kind"
// KindGroup marks a command created as a group container (see NewGroupCommand).
const KindGroup = "group"
// MarkGroup stamps cmd as a group container. Safe on a command without an
// existing Annotations map.
func MarkGroup(cmd *cobra.Command) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[KindAnnotation] = KindGroup
}
// IsGroup reports whether cmd was created as a group container.
func IsGroup(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[KindAnnotation] == KindGroup
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0 (the "License").
#
# check-phantom-overrides.py — publish-time guard against "phantom" CLI
# commands: toolOverrides in the discovery envelope whose backing MCP tool is
# not actually deployed on the server. Such overrides render in `dws <svc>
# --help` but fail at invocation ("tool not found"). This is the discovery-side
# (Plan A) complement to the runtime guard in internal/compat/dynamic_commands.go.
#
# TRUTH SOURCE: the live tools/list snapshots written by `dws cache refresh`
# into <cache-dir>/<partition>/tools/*.json, mapped to servers via
# <cache-dir>/<partition>/market/servers.json (cli.id slug -> server key).
#
# An override is a phantom ONLY if its tool name is absent from the resolved
# server's live tool set AND it is not otherwise explained:
# - serverOverride: resolve against the TARGET server's tool set;
# - pipeline: orchestrates multiple tools, no single backing tool;
# - redirectTo/target: a redirect stub, not a real tool invocation;
# - hidden:true: explicitly acknowledged (e.g. lead metadata for a tool
# not yet deployed) — reported as INFO, never fails CI.
#
# Exit code: 1 if any UN-acknowledged phantom override is found, else 0.
#
# Usage:
# dws cache refresh
# python3 scripts/dev/check-phantom-overrides.py
# python3 scripts/dev/check-phantom-overrides.py --envelope envelope/discovery.pre.json
import argparse
import glob
import json
import os
import sys
CLI_META_KEY = "com.dingtalk.mcp.registry/cli"
def load_real_tools(cache_dir, partition):
"""Return (name2tools, slug2tools): display-name and cli.id slug -> set(tool names)."""
base = os.path.join(cache_dir, partition)
real_by_key = {}
for f in glob.glob(os.path.join(base, "tools", "*.json")):
try:
d = json.load(open(f))
except (OSError, ValueError):
continue
names = {(t.get("name") or "").strip() for t in (d.get("tools") or [])}
names.discard("")
real_by_key[d.get("server_key")] = names
servers_path = os.path.join(base, "market", "servers.json")
if not os.path.exists(servers_path):
sys.exit(f"error: {servers_path} not found — run `dws cache refresh` first")
servers = json.load(open(servers_path)).get("servers", [])
name2tools, slug2tools = {}, {}
for s in servers:
ts = real_by_key.get(s.get("key"), set())
if s.get("display_name"):
name2tools[s["display_name"].strip()] = ts
slug = ((s.get("cli") or {}).get("id") or "").strip()
if slug:
slug2tools[slug] = ts
return name2tools, slug2tools, real_by_key
def cli_block(server):
"""toolOverrides live under _meta['com.dingtalk.mcp.registry/cli'] (flat slash key)."""
return (server.get("_meta", {}) or {}).get(CLI_META_KEY, {}) or {}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--envelope", default="envelope/discovery.pre.json")
ap.add_argument("--cache-dir", default=os.path.expanduser("~/.dws/cache"))
ap.add_argument("--partition", default="default_default")
args = ap.parse_args()
if not os.path.exists(args.envelope):
sys.exit(f"error: envelope not found: {args.envelope}")
name2tools, slug2tools, real_by_key = load_real_tools(args.cache_dir, args.partition)
if not real_by_key:
sys.exit("error: no tools snapshots in cache — run `dws cache refresh` first")
env = json.load(open(args.envelope))
phantom, acknowledged = [], []
for s in env.get("servers", []):
cli = cli_block(s)
name = (s.get("server", {}).get("name") or "").strip()
own = name2tools.get(name, set())
ov = cli.get("toolOverrides") or {}
for tool, o in ov.items():
so = (o.get("serverOverride") or "").strip()
if o.get("pipeline") or o.get("redirectTo") or o.get("target"):
continue
target = slug2tools.get(so, set()) if so else own
if tool in target:
continue
entry = (name, tool, o.get("cliName", ""), o.get("group", ""), so or "self")
(acknowledged if o.get("hidden") else phantom).append(entry)
if acknowledged:
print(f"INFO: {len(acknowledged)} acknowledged (hidden:true) phantom overrides — OK:")
for name, tool, cn, g, via in acknowledged:
print(f" [hidden] {name}: {tool} (cli={cn!r} group={g!r} via={via})")
if phantom:
print(f"\nFAIL: {len(phantom)} un-acknowledged phantom override(s) "
f"(tool not deployed; will fail at invocation):")
for name, tool, cn, g, via in phantom:
print(f" {name}: {tool} -> cli={cn!r} group={g!r} via={via}")
print("\nFix: remove the override, route via serverOverride/pipeline, "
"or mark hidden:true if it is intentional lead metadata.")
return 1
print(f"\nOK: no un-acknowledged phantom overrides in {args.envelope}")
return 0
if __name__ == "__main__":
sys.exit(main())
+623
View File
@@ -0,0 +1,623 @@
#!/usr/bin/env bash
# End-to-end regression script for multi-profile / multi-organization login.
# It uses an isolated DWS_CONFIG_DIR and DWS_KEYCHAIN_DIR, seeds post-login
# token results through the production auth storage API, then verifies the real
# dws CLI command surface.
#
# Usage:
# bash scripts/dev/test-multi-profile-e2e.sh
# bash scripts/dev/test-multi-profile-e2e.sh --skip-go-tests --verbose
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
RUN_GO_TESTS=1
VERBOSE=0
KEEP_WORKDIR=0
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-go-tests)
RUN_GO_TESTS=0
shift
;;
--verbose)
VERBOSE=1
shift
;;
--keep-workdir)
KEEP_WORKDIR=1
shift
;;
-h|--help)
sed -n '1,12p' "$0"
exit 0
;;
*)
echo "unknown option: $1" >&2
exit 2
;;
esac
done
mkdir -p "$ROOT/.tmp-bin"
WORKDIR="$(mktemp -d "$ROOT/.tmp-bin/multi-profile-e2e.XXXXXX")"
BIN="$WORKDIR/bin/dws"
HELPER_DIR="$WORKDIR/helper"
CONFIG_DIR="$WORKDIR/config"
KEYCHAIN_DIR="$WORKDIR/keychain"
CACHE_DIR="$WORKDIR/cache"
OUT_DIR="$WORKDIR/out"
cleanup() {
if [[ "$KEEP_WORKDIR" -eq 1 ]]; then
echo "[INFO] kept workdir: $WORKDIR"
else
rm -rf "$WORKDIR"
fi
}
trap cleanup EXIT
export DWS_CONFIG_DIR="$CONFIG_DIR"
export DWS_KEYCHAIN_DIR="$KEYCHAIN_DIR"
export DWS_DISABLE_KEYCHAIN=1
export DWS_CACHE_DIR="$CACHE_DIR"
export DWS_PERF_REPORT=
export DWS_PERF_DEBUG=
mkdir -p "$HELPER_DIR" "$CONFIG_DIR" "$KEYCHAIN_DIR" "$CACHE_DIR" "$OUT_DIR" "$(dirname "$BIN")"
log() {
printf '\n==> %s\n' "$*"
}
fail() {
echo "[FAIL] $*" >&2
exit 1
}
run() {
if [[ "$VERBOSE" -eq 1 ]]; then
"$@"
else
"$@" >/dev/null
fi
}
capture() {
local file="$1"
shift
if [[ "$VERBOSE" -eq 1 ]]; then
echo "+ $*" >&2
fi
"$@" >"$file" 2>"$file.stderr"
}
expect_contains() {
local file="$1"
local needle="$2"
if ! grep -F -- "$needle" "$file" >/dev/null; then
echo "----- $file -----" >&2
cat "$file" >&2
fail "expected $file to contain: $needle"
fi
}
expect_not_contains_line_command() {
local file="$1"
local command="$2"
if grep -E "^[[:space:]]+$command([[:space:]]|$)" "$file" >/dev/null; then
echo "----- $file -----" >&2
cat "$file" >&2
fail "did not expect command '$command' in $file"
fi
}
expect_fail() {
local needle="$1"
shift
local output
set +e
output="$("$@" 2>&1)"
local code=$?
set -e
if [[ "$code" -eq 0 ]]; then
echo "$output" >&2
fail "expected command to fail: $*"
fi
if ! grep -F -- "$needle" <<<"$output" >/dev/null; then
echo "$output" >&2
fail "expected failure output to contain: $needle"
fi
}
cat >"$HELPER_DIR/main.go" <<'GOEOF'
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
auth "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
)
type profileListResponse struct {
Success bool `json:"success"`
PrimaryProfile string `json:"primaryProfile"`
CurrentProfile string `json:"currentProfile"`
PreviousProfile string `json:"previousProfile"`
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"`
UserName string `json:"userName"`
Status string `json:"status"`
IsPrimary bool `json:"isPrimary"`
IsCurrent bool `json:"isCurrent"`
}
type authStatusResponse struct {
Success bool `json:"success"`
Authenticated bool `json:"authenticated"`
TokenValid bool `json:"token_valid"`
RefreshTokenValid bool `json:"refresh_token_valid"`
CorpID string `json:"corp_id"`
CorpName string `json:"corp_name"`
UserID string `json:"user_id"`
UserName string `json:"user_name"`
}
type multiProfileResponse struct {
Success bool `json:"success"`
MultiProfile bool `json:"multiProfile"`
Summary multiProfileSummary `json:"summary"`
Profiles []multiProfileResult `json:"profiles"`
}
type multiProfileSummary struct {
Total int `json:"total"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
}
type multiProfileResult struct {
Selector string `json:"selector"`
CorpID string `json:"corpId"`
CorpName string `json:"corpName"`
OK bool `json:"ok"`
Result map[string]any `json:"result"`
}
func main() {
if len(os.Args) < 2 {
die("missing helper command")
}
configDir := os.Getenv("DWS_CONFIG_DIR")
if strings.TrimSpace(configDir) == "" {
die("DWS_CONFIG_DIR is required")
}
switch os.Args[1] {
case "seed":
needArgs(7)
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
must(auth.SaveTokenData(configDir, data))
case "seed-legacy":
needArgs(7)
data := token(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6])
must(auth.SaveTokenDataKeychain(data))
must(auth.WriteTokenMarker(configDir))
case "write-app-config":
needArgs(4)
must(auth.SaveAppConfig(configDir, &auth.AppConfig{
ClientID: os.Args[2],
ClientSecret: auth.PlainSecret(os.Args[3]),
}))
case "assert-app-config":
needArgs(3)
cfg, err := auth.LoadAppConfig(configDir)
must(err)
switch os.Args[2] {
case "exists":
if cfg == nil || strings.TrimSpace(cfg.ClientID) == "" {
die("expected app config to exist")
}
case "absent":
if cfg != nil {
die("expected app config to be absent, got clientID=%q", cfg.ClientID)
}
default:
die("unknown app config expectation %q", os.Args[2])
}
case "assert-profiles":
needArgs(6)
cfg, err := auth.LoadProfiles(configDir)
must(err)
wantCount := atoi(os.Args[2])
if len(cfg.Profiles) != wantCount {
die("profiles len=%d, want %d: %#v", len(cfg.Profiles), wantCount, cfg.Profiles)
}
assertEqual("primaryProfile", cfg.PrimaryProfile, emptySentinel(os.Args[3]))
assertEqual("currentProfile", cfg.CurrentProfile, emptySentinel(os.Args[4]))
assertEqual("previousProfile", cfg.PreviousProfile, emptySentinel(os.Args[5]))
assertNoSecrets(configDir)
assertProfileMetadata(cfg)
case "assert-list-json":
needArgs(7)
var resp profileListResponse
raw := readJSON(os.Args[2], &resp)
if strings.Contains(string(raw), `"name"`) {
die("profile list JSON must not expose local name: %s", string(raw))
}
if !resp.Success {
die("profile list success=false")
}
wantCount := atoi(os.Args[3])
if len(resp.Profiles) != wantCount {
die("list profiles len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles)
}
assertEqual("list primaryProfile", resp.PrimaryProfile, emptySentinel(os.Args[4]))
assertEqual("list currentProfile", resp.CurrentProfile, emptySentinel(os.Args[5]))
assertEqual("list previousProfile", resp.PreviousProfile, emptySentinel(os.Args[6]))
for _, p := range resp.Profiles {
if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" {
die("profile list item missing corp identity: %#v", p)
}
if p.CorpID == resp.PrimaryProfile && !p.IsPrimary {
die("profile %s should be primary", p.CorpID)
}
if p.CorpID == resp.CurrentProfile && !p.IsCurrent {
die("profile %s should be current", p.CorpID)
}
}
case "assert-switch-json":
needArgs(5)
var resp profileUseResponse
readJSON(os.Args[2], &resp)
if !resp.Success {
die("switch JSON success=false")
}
assertEqual("switch corpId", resp.Profile.CorpID, os.Args[3])
assertEqual("switch corpName", resp.Profile.CorpName, os.Args[4])
if !resp.Profile.IsCurrent {
die("switch profile isCurrent=false")
}
case "assert-status-json":
needArgs(6)
var resp authStatusResponse
readJSON(os.Args[2], &resp)
if !resp.Success || !resp.Authenticated || !resp.TokenValid || !resp.RefreshTokenValid {
die("bad auth status response: %#v", resp)
}
assertEqual("status corpId", resp.CorpID, os.Args[3])
assertEqual("status corpName", resp.CorpName, os.Args[4])
assertEqual("status userId", resp.UserID, os.Args[5])
case "assert-multi-profile-json":
needArgs(5)
var resp multiProfileResponse
readJSON(os.Args[2], &resp)
if !resp.Success || !resp.MultiProfile {
die("bad multi-profile response: %#v", resp)
}
wantCount := atoi(os.Args[3])
if len(resp.Profiles) != wantCount {
die("multi-profile len=%d, want %d: %#v", len(resp.Profiles), wantCount, resp.Profiles)
}
if resp.Summary.Total != wantCount || resp.Summary.Succeeded != wantCount || resp.Summary.Failed != 0 {
die("bad multi-profile summary: %#v", resp.Summary)
}
wantCorpIDs := strings.Split(os.Args[4], ",")
if len(wantCorpIDs) != wantCount {
die("want corpId count=%d, want %d", len(wantCorpIDs), wantCount)
}
for i, want := range wantCorpIDs {
want = strings.TrimSpace(want)
got := resp.Profiles[i]
if !got.OK {
die("profile %d ok=false: %#v", i, got)
}
assertEqual(fmt.Sprintf("multi-profile corpId[%d]", i), got.CorpID, want)
if got.Result["_mock"] != true {
die("profile %s result is not mock payload: %#v", got.CorpID, got.Result)
}
}
case "assert-token":
needArgs(5)
data, err := loadToken(configDir, os.Args[2])
must(err)
assertEqual("token corpId", data.CorpID, os.Args[3])
assertEqual("token access", data.AccessToken, os.Args[4])
case "assert-empty-auth":
needArgs(2)
cfg, err := auth.LoadProfiles(configDir)
must(err)
if cfg.PrimaryProfile != "" || cfg.CurrentProfile != "" || cfg.PreviousProfile != "" || len(cfg.Profiles) != 0 {
die("expected empty profiles after reset, got %#v", cfg)
}
if auth.TokenDataExistsKeychain() {
die("legacy auth-token still exists")
}
case "assert-duplicate-name-fallback":
needArgs(4)
cfg, err := auth.LoadProfiles(configDir)
must(err)
p := findProfile(cfg, os.Args[2])
if p == nil {
die("profile %q not found", os.Args[2])
}
if p.CorpName != os.Args[3] {
die("profile %s corpName=%q, want %q", p.CorpID, p.CorpName, os.Args[3])
}
if p.Name == os.Args[3] || !strings.HasPrefix(p.Name, os.Args[3]+"-") {
die("profile %s name=%q, want stable fallback prefix %q", p.CorpID, p.Name, os.Args[3]+"-")
}
default:
die("unknown helper command %q", os.Args[1])
}
}
func token(corpID, corpName, userID, userName, access string) *auth.TokenData {
return &auth.TokenData{
AccessToken: access,
RefreshToken: "refresh-" + corpID,
PersistentCode: "persistent-" + corpID,
ExpiresAt: time.Now().Add(2 * time.Hour),
RefreshExpAt: time.Now().Add(720 * time.Hour),
CorpID: corpID,
CorpName: corpName,
UserID: userID,
UserName: userName,
ClientID: "client-" + corpID,
Source: "multi-profile-e2e",
}
}
func needArgs(n int) {
if len(os.Args) != n {
die("%s: got %d args, want %d", os.Args[1], len(os.Args)-2, n-2)
}
}
func loadToken(configDir, selector string) (*auth.TokenData, error) {
if selector == "default" {
return auth.LoadTokenData(configDir)
}
return auth.LoadTokenDataForProfile(configDir, selector)
}
func readJSON(path string, dst any) []byte {
data, err := os.ReadFile(path)
must(err)
if err := json.Unmarshal(data, dst); err != nil {
die("parse %s: %v\n%s", path, err, string(data))
}
return data
}
func assertProfileMetadata(cfg *auth.ProfilesConfig) {
names := map[string]string{}
for _, p := range cfg.Profiles {
if strings.TrimSpace(p.CorpID) == "" || strings.TrimSpace(p.CorpName) == "" {
die("profile missing corp metadata: %#v", p)
}
if prev, ok := names[p.Name]; ok {
die("duplicate profile local name %q for %s and %s", p.Name, prev, p.CorpID)
}
names[p.Name] = p.CorpID
}
}
func assertNoSecrets(configDir string) {
data, err := os.ReadFile(filepath.Join(configDir, "profiles.json"))
if err != nil {
if os.IsNotExist(err) {
return
}
must(err)
}
for _, forbidden := range []string{"access_token", "refresh_token", "persistent_code", "client_secret"} {
if strings.Contains(string(data), forbidden) {
die("profiles.json contains secret field %q", forbidden)
}
}
}
func findProfile(cfg *auth.ProfilesConfig, corpID string) *auth.Profile {
for i := range cfg.Profiles {
if cfg.Profiles[i].CorpID == corpID {
return &cfg.Profiles[i]
}
}
return nil
}
func atoi(raw string) int {
var n int
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil {
die("invalid integer %q", raw)
}
return n
}
func emptySentinel(s string) string {
if s == "_" {
return ""
}
return s
}
func assertEqual(label, got, want string) {
if got != want {
die("%s=%q, want %q", label, got, want)
}
}
func must(err error) {
if err != nil {
die("%v", err)
}
}
func die(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
GOEOF
cd "$ROOT"
if [[ "$RUN_GO_TESTS" -eq 1 ]]; then
log "running multi-profile Go regressions"
go test -timeout 180s -count=1 ./internal/auth ./internal/app ./test/cli
fi
log "building dws"
run go build -o "$BIN" ./cmd
helper() {
go run "$HELPER_DIR" "$@"
}
log "checking command surface"
capture "$OUT_DIR/root-help.txt" "$BIN" --help
expect_contains "$OUT_DIR/root-help.txt" "--profile"
expect_contains "$OUT_DIR/root-help.txt" "--yes"
expect_contains "$OUT_DIR/root-help.txt" "--dry-run"
expect_contains "$OUT_DIR/root-help.txt" "profile"
capture "$OUT_DIR/profile-help.txt" "$BIN" profile --help
expect_contains "$OUT_DIR/profile-help.txt" "list"
expect_contains "$OUT_DIR/profile-help.txt" "switch"
expect_contains "$OUT_DIR/profile-help.txt" "use"
expect_contains "$OUT_DIR/profile-help.txt" "--profile"
capture "$OUT_DIR/auth-login-help.txt" "$BIN" auth login --help
expect_contains "$OUT_DIR/auth-login-help.txt" "--device"
expect_contains "$OUT_DIR/auth-login-help.txt" "--token"
expect_contains "$OUT_DIR/auth-login-help.txt" "--recommend"
expect_contains "$OUT_DIR/auth-login-help.txt" "--yes"
capture "$OUT_DIR/skill-setup-help.txt" "$BIN" skill setup --help
expect_contains "$OUT_DIR/skill-setup-help.txt" "--mode"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--target"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--yes"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--skill"
expect_contains "$OUT_DIR/skill-setup-help.txt" "--exclude"
capture "$OUT_DIR/upgrade-help.txt" "$BIN" upgrade --help
expect_contains "$OUT_DIR/upgrade-help.txt" "--dry-run"
expect_contains "$OUT_DIR/upgrade-help.txt" "--yes"
capture "$OUT_DIR/dev-connect-help.txt" "$BIN" dev connect --help
expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-id"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--robot-client-secret"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--unified-app-id"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--agent-cmd"
expect_contains "$OUT_DIR/dev-connect-help.txt" "--daemon"
capture "$OUT_DIR/doc-delete-help.txt" "$BIN" doc delete --help
expect_contains "$OUT_DIR/doc-delete-help.txt" "--yes"
capture "$OUT_DIR/aitable-base-delete-help.txt" "$BIN" aitable base delete --help
expect_contains "$OUT_DIR/aitable-base-delete-help.txt" "--yes"
capture "$OUT_DIR/auth-help.txt" "$BIN" auth --help
expect_not_contains_line_command "$OUT_DIR/auth-help.txt" "switch"
log "verifying empty profile list"
capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-empty.json" 0 _ _ _
log "seeding first organization profile"
helper seed corp_alpha "Alpha Org" user_alpha "Alice Alpha" access-alpha-v1
capture "$OUT_DIR/list-alpha.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-alpha.json" 1 corp_alpha corp_alpha _
helper assert-profiles 1 corp_alpha corp_alpha _
helper assert-token default corp_alpha access-alpha-v1
helper assert-token corp_alpha corp_alpha access-alpha-v1
capture "$OUT_DIR/status-alpha-default.json" "$BIN" auth status --format json
helper assert-status-json "$OUT_DIR/status-alpha-default.json" corp_alpha "Alpha Org" user_alpha
log "seeding second organization profile"
helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v1
capture "$OUT_DIR/list-alpha-beta.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-alpha-beta.json" 2 corp_alpha corp_beta corp_alpha
helper assert-profiles 2 corp_alpha corp_beta corp_alpha
helper assert-token default corp_beta access-beta-v1
helper assert-token corp_alpha corp_alpha access-alpha-v1
helper assert-token corp_beta corp_beta access-beta-v1
log "refreshing existing organization without duplicating profile"
helper seed corp_beta "Beta Org" user_beta "Bob Beta" access-beta-v2
capture "$OUT_DIR/list-beta-refresh.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-beta-refresh.json" 2 corp_alpha corp_beta corp_alpha
helper assert-profiles 2 corp_alpha corp_beta corp_alpha
helper assert-token corp_beta corp_beta access-beta-v2
log "seeding duplicate organization name and checking stable fallback"
helper seed corp_gamma "Beta Org" user_gamma "Gina Gamma" access-gamma-v1
capture "$OUT_DIR/list-duplicate-name.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-duplicate-name.json" 3 corp_alpha corp_gamma corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_beta
helper assert-duplicate-name-fallback corp_gamma "Beta Org"
log "switching profiles and verifying legacy mirror"
capture "$OUT_DIR/switch-alpha.json" "$BIN" profile switch corp_alpha --format json
helper assert-switch-json "$OUT_DIR/switch-alpha.json" corp_alpha "Alpha Org"
helper assert-profiles 3 corp_alpha corp_alpha corp_gamma
helper assert-token default corp_alpha access-alpha-v1
capture "$OUT_DIR/switch-beta.txt" "$BIN" profile switch corp_beta --format table
expect_contains "$OUT_DIR/switch-beta.txt" "Beta Org"
expect_contains "$OUT_DIR/switch-beta.txt" "corp_beta"
helper assert-profiles 3 corp_alpha corp_beta corp_alpha
helper assert-token default corp_beta access-beta-v2
capture "$OUT_DIR/switch-previous.json" "$BIN" profile switch - --format json
helper assert-switch-json "$OUT_DIR/switch-previous.json" corp_alpha "Alpha Org"
helper assert-profiles 3 corp_alpha corp_alpha corp_beta
capture "$OUT_DIR/use-gamma.json" "$BIN" profile use corp_gamma --format json
helper assert-switch-json "$OUT_DIR/use-gamma.json" corp_gamma "Beta Org"
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
log "checking profile switch validation"
expect_fail "profile selector required" "$BIN" profile switch
expect_fail "只能指定一个组织选择器" "$BIN" profile switch corp_alpha --corpId corp_beta
expect_fail "missing_org" "$BIN" profile switch missing_org
log "checking one-shot profile override without changing current profile"
capture "$OUT_DIR/status-root-profile-alpha.json" "$BIN" --profile corp_alpha auth status --format json
helper assert-status-json "$OUT_DIR/status-root-profile-alpha.json" corp_alpha "Alpha Org" user_alpha
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
capture "$OUT_DIR/status-local-profile-beta.json" "$BIN" auth status --profile corp_beta --format json
helper assert-status-json "$OUT_DIR/status-local-profile-beta.json" corp_beta "Beta Org" user_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
capture "$OUT_DIR/status-current-gamma.json" "$BIN" auth status --format json
helper assert-status-json "$OUT_DIR/status-current-gamma.json" corp_gamma "Beta Org" user_gamma
capture "$OUT_DIR/contact-multi-profile.json" "$BIN" --mock --profile corp_alpha, corp_beta contact user get-self --format json
helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile.json" 2 corp_alpha,corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
capture "$OUT_DIR/contact-multi-profile-leaf-profile.json" "$BIN" --mock contact user get-self --profile corp_alpha, corp_beta --format json
helper assert-multi-profile-json "$OUT_DIR/contact-multi-profile-leaf-profile.json" 2 corp_alpha,corp_beta
helper assert-profiles 3 corp_alpha corp_gamma corp_alpha
log "checking auth reset cleanup"
helper write-app-config client-reset secret-reset
helper assert-app-config exists
capture "$OUT_DIR/auth-reset.txt" "$BIN" auth reset
expect_contains "$OUT_DIR/auth-reset.txt" "[OK]"
helper assert-empty-auth
helper assert-app-config absent
log "checking legacy single-slot migration"
helper seed-legacy corp_legacy "Legacy Org" user_legacy "Lena Legacy" access-legacy-v1
helper assert-profiles 0 _ _ _
capture "$OUT_DIR/list-legacy-migrated.json" "$BIN" profile list --format json
helper assert-list-json "$OUT_DIR/list-legacy-migrated.json" 1 corp_legacy corp_legacy _
helper assert-profiles 1 corp_legacy corp_legacy _
helper assert-token default corp_legacy access-legacy-v1
helper assert-token corp_legacy corp_legacy access-legacy-v1
log "multi-profile e2e passed"
echo "[PASS] isolated multi-profile chain completed"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0
#
# Build and install dws directly from a Git branch checkout.
#
# Usage:
# curl -fsSL https://raw.githubusercontent.com/shangguanxuan633-lab/dingtalk-workspace-cli/codex/dws-multi-profile-login/scripts/install-from-branch.sh | sh
#
# Environment variables:
# DWS_SOURCE_REPO owner/repo to clone (default: shangguanxuan633-lab/dingtalk-workspace-cli)
# DWS_SOURCE_BRANCH branch to build (default: codex/dws-multi-profile-login)
# DWS_INSTALL_DIR passed through to scripts/install.sh (default there: ~/.local/bin)
# DWS_INSTALL_NAME passed through to scripts/install.sh (default: dws)
# DWS_NO_SKILLS passed through to scripts/install.sh (set 1 to skip skills)
# DWS_KEEP_SOURCE set 1 to keep the temporary source checkout
set -eu
REPO="${DWS_SOURCE_REPO:-shangguanxuan633-lab/dingtalk-workspace-cli}"
BRANCH="${DWS_SOURCE_BRANCH:-codex/dws-multi-profile-login}"
KEEP_SOURCE="${DWS_KEEP_SOURCE:-0}"
say() {
printf ' %s\n' "$@"
}
err() {
printf ' ❌ %s\n' "$@" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"
}
need_cmd git
need_cmd sh
tmpdir="$(mktemp -d 2>/dev/null || mktemp -d -t dws-src)"
cleanup() {
if [ "$KEEP_SOURCE" != "1" ]; then
rm -rf "$tmpdir"
else
say "Source checkout kept at: $tmpdir"
fi
}
trap cleanup EXIT INT TERM
say "Cloning dws source:"
say " repo: https://github.com/${REPO}.git"
say " branch: ${BRANCH}"
git clone --depth 1 --branch "$BRANCH" "https://github.com/${REPO}.git" "$tmpdir"
say "Building and installing from source..."
sh "$tmpdir/scripts/install.sh"
+31 -4
View File
@@ -27,6 +27,7 @@ cli_version: ">=1.0.15"
- **脚本优先**:[scripts/](./scripts/) 下的 `python scripts/<name>.py` 已封装翻页/轮询/批量逻辑,遇到对应场景(如 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等)**优先调用脚本**而非手写多步命令。脚本均支持 `--dry-run` 预览、`--format json` 输出,失败时回退到手动步骤
- **业务域最佳实践优先**:文档类多步任务先读 [04-document.md](./references/best_practices/04-document.md);AI 表格读取/统计/写入/导入导出先读 [06-data-analytics.md](./references/best_practices/06-data-analytics.md)。本仓库只迁入这些业务域 best practices,不引入其它产品行动指南。
- 知识库容器只用 `dws wiki space/member`;知识库内文件/文档的浏览、搜索、读取、创建、移动、复制统一切到 `dws doc`。`workspaceId` 只能传给 `wiki --workspace`、`doc --workspace` 或 `doc search --workspace-ids`,禁止传给 `doc list --folder`,也不要使用不存在的 `--space-id`。
- 找群 / 找人 / 找数据在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile <corpId>` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。
## 开放平台文档 RAG / 错误码排查
@@ -44,10 +45,10 @@ cli_version: ">=1.0.15"
|-------------------|------------------------------------------------------|----------------------------------------------------------------|
| `aisearch` | AI搜问(搜人首选):按姓名/部门/职位/职责/上级/下级/手机号/工号维度找人,"谁负责 XX/XX 的负责人/某事项/某项目的人"统一走本产品 | [aisearch.md](./references/products/aisearch.md) |
| `aitable` | AI表格:Base/数据表/字段/记录/视图/附件/图表/仪表盘/导入导出/模板搜索 | [aitable.md](./references/products/aitable.md) |
| `attendance` | 考勤:打卡结果/打卡流水/考勤组查询/考勤规则/汇总统计/假期类型/假期余额(P0 已落地,部分管理类命令仍属 P1) | [attendance.md](./references/products/attendance.md) |
| `attendance` | 考勤:考勤组与规则查询(rules)/个人打卡详情(record get)/批量班次查询(shift list)/考勤统计摘要(summary),仅此 4 个命令组 | [attendance.md](./references/products/attendance.md) |
| `calendar` | 日历:日历列表/日程/参与者/附件/响应/会议室/闲忙查询/时间建议 | [calendar.md](./references/products/calendar.md) |
| `chat` | 群聊与机器人:搜索群/建群/群成员管理/改群名/消息发送(文本/Markdown/图片/文件)/拉取消息/@我/特别关注/机器人群发/单聊/撤回/转发/引用回复/Webhook/**查询**已有机器人 | [chat.md](./references/products/chat.md) |
| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/花名册档案(学历/家庭/银行卡/合同)/离职员工查询(姓名/时间范围/部门)/部门查询(搜索/详情/子部门/成员)/角色查询(主管/管理员/财务/HR 等 label)/特别关注列表 | [contact.md](./references/products/contact.md) |
| `contact` | 通讯录:用户查询(当前用户/搜索/详情/手机号)/花名册档案(学历/家庭/银行卡/合同)/离职员工查询(姓名/时间范围/部门)/部门查询(搜索/详情/子部门/成员)/特别关注列表(按角色/职责找人请走 `aisearch`) | [contact.md](./references/products/contact.md) |
| `dev` | 开放平台开发者:**新建/配置机器人(建号)**、建联调试(把机器人接到本地 agent 的 Stream)、应用生命周期(创建/更新/删除/凭证/权限/成员/事件订阅)、开放平台文档搜索 | [dev.md](./references/products/dev.md) |
| `devdoc` | 开放平台文档:搜索开发文档 | [devdoc.md](./references/products/devdoc.md) |
| `ding` | DING消息:发送/撤回(应用内/短信/电话) | [ding.md](./references/products/ding.md) |
@@ -72,6 +73,30 @@ cli_version: ">=1.0.15"
4. **Fallback 单产品路由**:仅当行动指南未命中,且用户意图明确是单一产品单步操作时,才按「产品总览」和「意图判断决策树」选择产品,并读取对应 `references/products/*.md`。
5. **追问**:以上步骤都无法判断时,主动追问用户澄清,严禁猜测命令、flag、URL、ID 或字段名。
## 多组织处理
dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 按当前 profile 自动注入,不是只支持单组织)。
**触发条件(命中任一即进入本节)**:
- 显式:用户提到 切换 / 换 / 跨组织、另一个钉钉、别的公司、看登录了哪些组织、当前是哪个组织、某人 / 某群 / 某数据在别的组织
- 隐式(最常见、易漏):在当前组织读 / 搜没找到目标(群 / 人 / 数据),且 `dws profile list` 显示已登录 ≥2 个组织 —— 别急着判「不存在」,按下方跨组织铁律去其他组织找
- 需要跨多个组织汇总 / 对比数据
- 用户问认证状态 / 登录了哪些组织 / 主组织是哪个
**不触发**:只登录 1 个组织时,按当前组织正常处理,不带 `--profile`,不进本节。
命令:
- `dws profile list` — 列出已登录组织(主 / 当前标记、状态、有效期),只读元数据
- `dws profile switch <名称|corpId|->` — 持久切换当前组织;`-` 切回上一个;无参数在交互终端弹选择器(非交互须显式传参)。`dws profile use` 是其别名
- 全局 `--profile <名称|corpId>` — 单次指定本命令用哪个组织,一次性、不改当前组织
- `dws auth login` — 再登一个组织即新增 profile(自动从授权账号取 corpId / corpName);同组织重复 login = 刷新
- `dws auth status [--profile <名称>]` — 查看认证状态
多组织数据聚合步骤:`dws profile list` 拿到所有已登录组织,对每个组织带 `--profile <corpId>` 各取一次数,合并并标注来源组织;某组织失败则标「该组织暂不可用」并继续返回其余。
安全护栏:
- 只有 `dws profile list` 显示 ≥2 个组织才启用上面的跨组织逻辑;单组织直接按当前组织走,不带 `--profile`。
- 自动跨组织只对「读 / 搜」。写 / 发 / 删 / 撤回等操作默认只在当前组织做;确需带 `--profile` 跨组织写时,必须先与用户确认目标组织。
- 持久切换 `dws profile switch`(改默认组织)按写操作对待:未经用户明确要求不得执行。跨组织找数一律用一次性 `--profile`,不改当前组织。
## 行动指南(优先匹配)
> 将用户意图与下表做**语义比对**,不要求字面包含关键词。命中后必须读取该行动指南文件,并按其中固定路线执行;多个场景同时命中时,按下方「消歧规则」选择。
@@ -94,13 +119,13 @@ cli_version: ">=1.0.15"
## 意图判断决策树
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/某职责/某角色(主管/管理员/财务/HR 等)由谁担任/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`(按角色或职责找人用 `aisearch person --dimension duty`)
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
用户提到"考勤/打卡/排班" → `attendance`
用户提到"日程/日历/会议室/约会/时间建议" → `calendar`
用户提到"群聊/建群/群成员/群管理/发消息/发图片消息/发文件消息/发 Markdown 消息/截图发钉钉/转发消息/引用回复/@我/特别关注消息/机器人发消息/Webhook/机器人群发/机器人单聊/通知" → `chat`(仅 IM 操作;创建/配置/建联机器人走 `dev`,见下)
用户提到"创建机器人/新建机器人/建机器人/配置机器人/机器人建号/建联/把机器人连到/接入 agent/opencode/claude/qoder/连接 agent/开放平台应用/开发者应用/app 创建/应用凭证/事件订阅/dws dev" → `dev`
用户提到"通讯录/同事/部门/组织架构/子部门/部门多少人/离职员工/离职名单/离职花名册/花名册/员工档案/学历/家庭/银行卡/紧急联系人/合同/角色/主管角色/管理员角色/财务/HR/特别关注/星标联系人" → `contact`
用户提到"通讯录/同事/部门/组织架构/子部门/部门多少人/离职员工/离职名单/离职花名册/花名册/员工档案/学历/家庭/银行卡/紧急联系人/合同/特别关注/星标联系人" → `contact`(按角色/职责找人不在此,走 `aisearch`)
用户提到"开发/API/调用错误 文档" → `devdoc`
用户提到"DING/紧急消息/电话提醒" → `ding`
用户提到"钉钉文档/云文档/读写文档/知识库里的文档/浏览知识库内容/知识库内搜索文档/块级编辑/文档评论/文档复制移动" → `doc`
@@ -112,6 +137,7 @@ cli_version: ">=1.0.15"
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"切换组织/换组织/跨组织/另一个钉钉/别的公司/多组织/看所有组织/profile/登录了哪些组织" → `profile`(见「多组织 / profile」节)
关键区分: **dev(创建/配置/建联机器人)** vs **chat(查询/发消息已有机器人)**。`dws chat bot search/find` 只查询机器人;**建号**(创建钉钉智能体机器人)走 `dws dev app robot submit`;**建联**(把机器人接到本地 agent 的 Stream)走 `dws dev connect`。凡是"创建机器人""建机器人""接入 agent""建联"一律路由到 `dev`,禁止走 `chat`。
关键区分: aitable(数据表格) vs todo(待办任务)
@@ -149,6 +175,7 @@ cli_version: ">=1.0.15"
| `oa` | `approval reject` | 拒绝待审批(需加明确理由) |
| `todo` | `task delete` | 删除待办 |
| `minutes` | `replace-text` | 全文批量替换转写与摘要 |
| `auth` | `logout` | **默认退出所有已登录组织**;只退一个加 `--profile <名称\|corpId>`。注意:退主组织不会被拦,会静默把「主」改选为剩下第一个组织,退主前必须向用户确认 |
### 确认流程
```
@@ -5,7 +5,7 @@
## 专用规则(#8 非 lite 步骤必守)
- **角色类查人优先 label**:用户说"角色为XX的员工/XX角色的员工/XX角色的人员""所有主管/主管理员/财务/HR/总经理"等角色类型人员时,**优先** `contact label list` 获取全部角色 → 匹配目标角色 → `contact label list-members --id <labelId>`;若用户明确指定了角色名称(如"角色为总经理"),则先用 `contact label get --names <XX>` 精确匹配,**若精确匹配无结果,降级 `label list` 模糊匹配**(如用户说"管理员"可匹配到"主管理员"和"子管理员")。
- **角色/职责类查人优先 aisearch**:用户说"角色为XX的员工/XX角色的人员""所有主管/财务/HR/总经理""谁负责 XX"等角色/职责类查询时,**优先** `aisearch person --keyword "<角色或职责>" --dimension duty`(`contact label` 角色查询命令已下线,不再可用)。
- **脚本优先**:按部门拉成员**优先** `python scripts/contact_dept_members.py --query "<部门名>"`(`--dry-run` / `--format json`);失败再 `dept search` → `dept list-members --depts`。
- **详情链路**:用户要子部门、职位、联系方式、汇报关系等,在 `user search` 之后**必须**再 `contact user get --ids <userId>`;禁止仅用 search 的浅表字段交差。
- **`user get` 后部门仍空**:不得过早结束或只建议用户去 App;须在 CLI 能力内尝试 **用户点名的部门** `dept search` + `dept list-members` 等与 `userId` 交叉核对,再结构化汇总「返回中有哪些字段 / 哪些为空及可能原因」。
@@ -20,7 +20,7 @@
## 与其他场景消歧
- **按角色/职位类型查人(主管/管理员/财务等)** → 优先 `contact label list` + `label list-members`;label 精确命中角色维度,返回完整名单;aisearch 是语义模糊搜索不保证完整性。
- **按角色/职位类型查人(主管/管理员/财务等)** → 优先 `aisearch person --dimension duty`(按职责维度找人);`contact label` 角色查询命令已下线。
- **搜人/找人/找同事/查工号/查手机号** → 首选 **`aisearch person`**(AI 语义搜索,支持姓名/部门/职责/上下级/手机号/工号维度),见 `aisearch`(开源版未引入,悟空内部产品)。
- **需要 userId 做后续操作 / 按手机号查 / 按 userId 查详情** → `contact`(精确查询)。
- **纯查部门与子部门成员 / 验证归属 / 组织关系** → `contact`。
@@ -31,7 +31,7 @@
| Recipe | 步骤 |
|--------|------|
| `lookup-label-members` | 1. `contact label list` → 浏览全部角色,匹配目标角色的 labelId<br>2. `contact label list-members --id <labelId>` → 该角色下的成员列表 |
| `lookup-role-members` | 1. `aisearch person --keyword "<角色或职责>" --dimension duty` → 按职责维度找人(`contact label` 已下线) |
| `search-user-by-mobile` | 1. `contact user search-mobile --mobile "<手机号>"` → 按需 `contact user get --ids <userId>` |
| `lookup-dept-id` | 1. `contact dept search --query "<部门关键词>"` → 回显 `deptId`(多命中须消歧) |
| `list-subdepts` | 1. 已有父 `deptId` → `contact dept list-children --dept <父deptId>` 直接取直属子部门列表<br>2. 只有部门名 → 先 `lookup-dept-id` 取 `deptId`,再 `list-children` |
+2 -2
View File
@@ -82,10 +82,10 @@
**用 `report` 的场景**:
- "帮我看看收到的日报" — 日志收件箱
- "帮我写/提交今天的日报(钉钉日志模版)" — 先 `report template list` / `template detail`,再 `report create`
- "帮我写/提交今天的日报(钉钉日志模版)" — 先 `report template list` / `template get`,再 `report entry submit --contents-file <tmp>.json`
- "有什么日志模版" — 查看模版
- "看看这个日志的已读统计" — 阅读状态
- "我发过的日志有哪些" — 已发送列表 (`report sent`)
- "我发过的日志有哪些" — 已发送列表 (`report outbox list`)
- 用户提到"日报"、"周报"、"日志"
**用 `todo` 的场景**:
File diff suppressed because it is too large Load Diff
+22 -22
View File
@@ -101,8 +101,6 @@ Flags:
**默认行为**:不传 `--start` / `--end` 时,默认返回今天的日程(00:00:00 ~ 23:59:59)。
**权限**:查询共享日历下的日程时,至少要有reader权限。
补充:当前用户的个人日程也可用 `dws calendar event list-mine` 查询,参数与 `event list` 一致。
### 获取日程详情
```
Usage:
@@ -219,12 +217,13 @@ Flags:
Usage:
dws calendar attendee add [flags]
Example:
dws calendar attendee add --event <EVENT_ID> --attendees <USER_ID_1>,<USER_ID_2>
dws calendar attendee add --event <EVENT_ID> --attendees <USER_ID> --optional
dws calendar attendee add --event <EVENT_ID> --users <USER_ID_1>,<USER_ID_2>
dws calendar attendee add --event <EVENT_ID> --users <USER_ID> --optional true
Flags:
--event string 日程 ID (必填)
--attendees string 参会人 userId 列表,逗号分隔 (必填,最多500人)
--optional 参会人可选 (默认必选参会人)
--event string 日程 eventId (必填)
--users string 参会人 userId 列表,逗号分隔 (必填)
--optional string 是否可选参会人 (可选)
--calendar-id string 日历 ID (可选)
```
### 移除参会人
@@ -235,10 +234,11 @@ Flags:
Usage:
dws calendar attendee delete [flags]
Example:
dws calendar attendee delete --event <EVENT_ID> --attendees <USER_ID> --yes
dws calendar attendee delete --event <EVENT_ID> --users <USER_ID> --yes
Flags:
--event string 日程 ID (必填)
--attendees string 参会人 userId 列表,逗号分隔 (必填)
--event string 日程 eventId (必填)
--users string 参会人 userId 列表,逗号分隔 (必填)
--calendar-id string 日历 ID (可选)
```
### 搜索会议室
@@ -317,10 +317,10 @@ Usage:
dws calendar room list-groups [flags]
Example:
dws calendar room list-groups
dws calendar room list-groups --limit 20 --page 0
dws calendar room list-groups --page-size 20 --page-index 0
Flags:
--limit string 页大小 (可选,不填默认 100,超过 100 按 100 处理)
--page string 分页起始位置 (可选,不填默认 0)
--page-size string 页大小 (可选,默认 100,上限 100)
--page-index string 分页起始位置 (可选,默认 0)
```
### 添加日程附件
@@ -419,8 +419,8 @@ Flags:
用户说"参会人/与会者":
- 查看 → `attendee list`
- 邀请/添加 → `attendee add --attendees <USER_ID>`(可选参会人加 `--optional`)
- 移除 → `attendee delete --attendees <USER_ID>`
- 邀请/添加 → `attendee add --users <USER_ID>`(可选参会人加 `--optional true`)
- 移除 → `attendee delete --users <USER_ID>`
用户说"会议室/订会议室":
- 哪个空闲 → `room search`
@@ -479,7 +479,7 @@ dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json
# Step 2: 添加参会人(必须用 Step 1 返回的 eventId)
dws calendar attendee add --event <EVENT_ID> --attendees userId1,userId2 --format json
dws calendar attendee add --event <EVENT_ID> --users userId1,userId2 --format json
# Step 3: 搜索空闲会议室
dws calendar room search --start ... --end ... --format json
@@ -519,19 +519,19 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
- 时间格式: `event create/update`、`event list`、`busy search` 和 `event suggest` 用 ISO-8601
- 时区: `event create/update` 和 `event suggest` 支持 `--timezone` 指定 IANA 时区(如 `Asia/Shanghai`、`America/New_York`),不传默认 `Asia/Shanghai`
- 创建日程时可通过 `--attendees` 直接指定参会人(最多500人),也可创建后用 `attendee add --attendees ...` 单独添加
- `--attendees` 和 `--open-dingtalk-ids` 至少传一个(如果需要指定参会人)
- 添加参会人时可通过 `--optional` 设为可选参会人(默认必选)
- 创建日程时可通过 `--attendees` 直接指定参会人(最多500人),也可创建后用 `attendee add --users ...` 单独添加
- `event create` 的 `--attendees` 和 `--open-dingtalk-ids` 至少传一个(如果需要指定参会人)
- `attendee add` 可通过 `--optional true` 设为可选参会人(默认必选)
- `event suggest` 根据参会人闲忙自动推荐合适时间,适合会议时间未确定时使用
- 创建日程**支持**通过 `--rooms` 一步预定会议室(`event create --rooms roomId1,roomId2`);若创建后再加,仍可用 `room add`
- `room search` 不带 `--group-id` 时查根目录;企业会议室超过 100 条会报错,此时需先 `room list-groups` 获取分组,再按分组逐一查询
- `room list-groups` 支持 `--limit` / `--page` 分页(schema 类型为字符串)
- `room list-groups` 支持 `--page-size` / `--page-index` 分页(schema 类型为字符串)
- **`event create --rooms` / `room add --rooms` 的唯一合法来源**:最近一次(同一会话、同一时段窗口)`room search` 返回体中的 `roomId`;禁止把用户自然语言会议室名当 `roomId` 传入(否则会 `roomId invalid` 等错误)
- **搜房无结果**:在符合早停/用户限定范围内,`room search`(含按分组逐组查)全部返回空或无空闲 → 应**直接向用户报错/说明失败**并结束订房;**禁止**假设 roomId、禁止无合法 `roomId` 时调用 `room add` / `event create --rooms` 试探、禁止用 `event get` 等绕路推断 roomId
- **评测 / 自动化断言**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId`
- **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `<fileId>:<name>` 元素逗号分隔
- **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历
- **已弃用入参**:`--max-results`(event list / list-mine)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用
- **已弃用入参**:`--max-results`(event list)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用
## 自动化脚本
@@ -544,4 +544,4 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
## 相关产品
- [conference](./simple.md) — 仅视频会议预约(返回入会链接),不含参会人/会议室管理
- [contact](./contact.md) — 搜索同事 userId,用于 attendee add --attendees
- [contact](./contact.md) — 搜索同事 userId,用于 attendee add --users
+29 -40
View File
@@ -18,9 +18,9 @@ Flags:
#### 查看群成员列表 — 分页查询指定群聊的成员
```
Usage:
dws chat group members [flags]
dws chat group members list [flags]
Example:
dws chat group members --id <openconversation_id>
dws chat group members list --id <openconversation_id>
Flags:
--cursor string 分页游标,首次从 0 开始
--id string 群 ID / openconversation_id (必填)
@@ -100,13 +100,11 @@ Usage:
dws chat group transfer-owner [flags]
Example:
dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId>
dws chat group transfer-owner --group <openConversationId> --user <userId>
# 查询群 ID: dws chat search --query "群名"
# 查询人员: dws aisearch person --keyword "姓名" --dimension name
Flags:
--group string 群聊 openConversationId (必填)
--new-owner string 新群主 openDingTalkId
--user string 新群主 userId
--new-owner string 新群主 openDingTalkId (必填)
```
#### 获取群邀请链接 — 获取指定群聊的邀请加入链接
@@ -390,6 +388,8 @@ Flags:
**重要:该接口会真实发送消息到目标会话,不可用于测试或试探性调用。调用前必须确认消息内容和接收对象无误。**
**`--ai-tag` 默认开(默认 true)**:dws 发送的消息默认带「通过AI发送」角标,正常发无需特意加;仅当用户要求按本人发、不带角标时传 `--ai-tag=false`。仅 send / reply 支持。
--group 指定群聊 openConversationId 发群消息;--user 指定用户 userId 发单聊;--open-dingtalk-id 指定用户 openDingTalkId 发单聊。三者只能选其一,不能同时指定。纯文本/Markdown 单聊传 --user 时直接走 userId 发送能力,不需要先手动查询 openDingTalkId。推荐使用 --text flag 传递消息内容(也支持位置参数)。可选 --title 作为消息标题。
若用户只提供了数字群号而非 openConversationId,需先调用 `chat group get-by-group-id` 将群号转为 openConversationId,再传入 --group。
--群聊时可选 --at-all @所有人,或 --at-open-dingtalk-ids 指定成员(仅群聊时生效)。
@@ -413,6 +413,10 @@ Example:
dws chat message send --group <openconversation_id> --text "hello" --uuid "unique-id-123"
dws chat message send --group <openconversation_id> --at-all "@all 请大家注意"
dws chat message send --group <openconversation_id> --at-open-dingtalk-ids openDingTalkId1,openDingTalkId2 "<@openDingTalkId1> <@openDingTalkId2> 请查收"
# 默认即带「通过AI发送」角标,无需特意加 --ai-tag
dws chat message send --user <userId> --text "已处理好了"
# 仅当用户要求按本人发送、不带角标时
dws chat message send --user <userId> --text "已处理好了" --ai-tag=false
# 发送图片
dws chat message send --group <openconversation_id> --msg-type image --media-id <mediaId>
# 发送文件(音频/视频/文档等非图片文件统一走钉盘上传)
@@ -428,6 +432,7 @@ Flags:
--title string 消息标题(可选,默认「消息」)
--at-all @所有人(仅群聊时生效,可选,默认 false)
--at-open-dingtalk-ids string @指定成员的 openDingTalkId 列表,逗号分隔(仅群聊时生效,可选)
--ai-tag 标记为「通过AI发送」角标,默认 true(默认带上);传 --ai-tag=false 关闭(按本人发送)
--media-id string 图片 mediaId(dt_media_upload 上传后用 `python scripts/extract_media_id.py <URL>` 提取,仅 msgType=image)
--msg-type string 消息类型: image/file(image 用 mediaId,file 用钉盘上传)
--dentry-id int64 钉盘文件 dentryId(msgType=file 时必填,通过 drive info 获取)
@@ -511,28 +516,17 @@ Usage:
Example:
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --title "日报" --text "## 今日完成..."
dws chat message send-by-bot --robot-code <robot-code> --users userId1,userId2 --title "提醒" --text "请提交周报"
dws chat message send-by-bot --robot-code <robot-code> --open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "请提交周报"
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-user-ids userId1,userId2 --title "提醒" --text "@userId1 @userId2 请查收本周报告"
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-open-dingtalk-ids openDingtalkId1,openDingtalkId2 --title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告"
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> --at-all --title "通知" --text "请所有人注意"
Flags:
--group string 群聊 openConversationId(群聊时必填)
--robot-code string 机器人 Code (必填)
--text string 消息内容 Markdown (必填)
--title string 消息标题 (必填)
--users string 用户 userId 列表,逗号分隔,最多20个(单聊时必填)
--open-dingtalk-ids string 用户 openDingtalkId 列表,逗号分隔(单聊时可替代 --users,可选)
--at-user-ids string @指定成员的 userId 列表,逗号分隔(仅群聊时生效,可选)
--at-open-dingtalk-ids string @指定成员的 openDingtalkId 列表,逗号分隔(仅群聊时生效,可选)
--at-all @所有人(可选),服务端接收字符串 true/false
--users string 接收者 userId 列表,逗号分隔,最多20个(单聊时必填)
注意:
- 用户明确要求机器人发送时,必须使用 `chat message send-by-bot`;严禁使用 `chat message send` 以用户身份代发
- --group 与 --users/--open-dingtalk-ids 互斥,必须且只能指定其一
- --group 的别名: --id, --chat, --conversation-id (均可替代 --group)
- --at-user-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @userId 对应文本
- --at-open-dingtalk-ids 仅在 --group 群聊时生效,单聊时无效;设置时 --text 中需包含 @openDingtalkId 对应文本
- --at-all @所有人,仅群聊时生效;只需带上 --at-all flag 即可,服务端会自动处理
- --group(群聊)与 --users(单聊)互斥,必须且只能指定其一
- send-by-bot 不支持 @成员/@所有人 参数(无 --at-user-ids/--at-open-dingtalk-ids/--at-all);如需在群里 @人,用 `chat message send --group` 走用户身份发送
- userId 获取方式:`dws contact user search --query "姓名"` 搜人获取 userId
- **换行符**:--text 按 Markdown 渲染,换行规则同 `chat message send`:
1. 必须使用**真实换行符**(`U+000A`),而非字面量 `\n`,否则全部内容会渲染在同一行
@@ -1179,7 +1173,7 @@ Flags:
用户说"建群/创建群聊" → `chat group create`
用户说"搜索群/找群" → `chat search`
用户说"我创建的群/我管理的群/我是群主的群/我当管理员的群" → `chat group list-my-groups`
用户说"群成员/看群里有谁" → `chat group members`
用户说"群成员/看群里有谁" → `chat group members list`
用户说"拉人进群/加群成员" → `chat group members add`
用户说"踢人/移除群成员" → `chat group members remove`
用户说"加机器人到群" → `chat group members add-bot`
@@ -1351,7 +1345,7 @@ dws chat message send-by-bot --robot-code <robot-code> --group <openconversation
--title "通知" --text "内容" --format json
# Step 3: 邀请机器人进群
dws chat group members add-bot --group <openconversation_id> --robot-code <robot-code>
dws chat group members add-bot --id <openconversation_id> --robot-code <robot-code>
# Step 4: 重新发送
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
@@ -1370,27 +1364,22 @@ dws chat bot find --query "玉澜" --format json
dws chat message send --open-dingtalk-id <openDingTalkId> --text "你好" --format json
```
### 机器人 @指定人发群消息
### @指定人发群消息(用户身份)
通过 `--at-user-ids` 传入 userId 列表或 `--at-open-dingtalk-ids` 传入 openDingtalkId 列表来 @指定成员,多个用逗号分隔。`--text` 中需包含 `@userId` 或 `@openDingtalkId` 文本(不要用尖括号,不要用姓名)。通过 `--at-all` @所有人。
`send-by-bot` 不支持 @ 参数。群里 @人/@所有人请用 `chat message send --group`(当前用户身份):通过 `--at-open-dingtalk-ids` 传入 openDingTalkId 列表 @指定成员(多个逗号分隔),`--text` 中需包含对应 `@openDingTalkId` 文本;`--at-all` @所有人。
```bash
# Step 1: 搜人获取 userId
# Step 1: 搜人获取 openDingTalkId
dws contact user search --query "张三" --format json
# Step 2: 用 userId 发送并 @(注意 text 中 @userId)
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--at-user-ids userId1,userId2 \
--title "提醒" --text "@userId1 @userId2 请查收本周报告" --format json
# 或者用 openDingtalkId 发送并 @
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
# Step 2: @指定成员发群消息(注意 text 中带 @openDingTalkId)
dws chat message send --group <openconversation_id> \
--at-open-dingtalk-ids openDingtalkId1,openDingtalkId2 \
--title "提醒" --text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" --format json
--text "@openDingtalkId1 @openDingtalkId2 请查收本周报告" --format json
# @所有人
dws chat message send-by-bot --robot-code <robot-code> --group <openconversation_id> \
--at-all --title "通知" --text "请所有人注意" --format json
dws chat message send --group <openconversation_id> \
--at-all --text "请所有人注意" --format json
```
@@ -1467,8 +1456,8 @@ Flags:
| `chat search` | `openConversationId` | message send/list、group members 等的 --group |
| `chat group create` | `openConversationId` | 同上 |
| `chat message list-all` | `nextCursor` | 下次 list-all 的 --cursor |
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、send-by-bot 的 --at-user-ids、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、send-by-bot 的 --open-dingtalk-ids、send-by-bot 的 --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id |
| `aisearch person` | `userId` | message send 的 --user、list-direct 的 --user、send-by-bot 的 --users、list-by-sender 的 --sender-user-id |
| `aisearch person` → `contact user get` | `openDingTalkId` | message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-direct 的 --open-dingtalk-id、list-by-sender 的 --sender-open-dingtalk-id |
| `chat bot search` | `robotCode` | send-by-bot / recall-by-bot 的 --robot-code(仅我创建的机器人,无 openDingTalkId) |
| `chat bot find` | `openDingTalkId` | 给机器人发单聊消息(全部可用机器人,额外返回 openDingTalkId) |
| `chat message send-by-bot` | `processQueryKey` | recall-by-bot 的 --keys |
@@ -1514,12 +1503,12 @@ Flags:
- `--user` 和 `--open-dingtalk-id` 本质上都是发起单聊操作,只是用户标识格式不同:userId 为企业内部应用常用标识,openDingTalkId 为三方应用或跨组织场景下的用户标识,服务端对两种 ID 的解析逻辑不同
- `--time` 格式: `yyyy-MM-dd HH:mm:ss`,为拉取消息的起始时间点;`--forward` 控制方向(默认 true,拉给定时间之后的消息),`--limit` 控制数量
- `chat search` 挂在 `chat` 下(非 `chat group` 下),路径为 `dws chat search`
- `send-by-bot` 群聊传 `--group`,单聊传 `--users` 或 `--open-dingtalk-ids`,与 `--group` 互斥且必选其一;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingtalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --group <openConversationId> --robot-code <robot-code>` 将机器人邀请进群后再发送
- `send-by-bot` 群聊传 `--group`,单聊传 `--users`,二者互斥且必选其一;**不支持 @成员/@所有人**(无 `--at-user-ids`/`--at-open-dingtalk-ids`/`--at-all`),群里 @人请用 `chat message send --group` 走用户身份;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --id <openConversationId> --robot-code <robot-code>` 将机器人邀请进群后再发送
- `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回)
- `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--text` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--text` 中包含 `@10`
- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user <userId>`
- 消息**换行符**(`send` / `send-by-bot` / `send-by-webhook` 的 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `<br>`(硬换行)
- `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner`
- `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主用 `--new-owner`(传 openDingTalkId)
- `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久)
- `chat group quit` 退出群聊,需传 --group(openConversationId)
- `chat group update-icon` 更新群头像,需传 --group(openConversationId)和 --icon-media-id(mediaId)
@@ -1535,7 +1524,7 @@ Flags:
- `chat message reply` 引用回复消息(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段)、--ref-msg-id(被引用消息 openMessageId)、--ref-sender(被引用消息发送者 openDingTalkId)、--text(回复内容);目前回复类型仅支持 text
- `chat message forward` 转发单条消息(**源/目标会话均支持单聊/群聊**,常见组合:群→群、群→单、单→群、单→单),需传 --src-conversation-id(源会话 openConversationId)、--msg-id(源消息 openMessageId)、--dest-conversation-id(目标会话 openConversationId)
- `chat set-top` 设置/取消会话置顶(**单聊/群聊均可**),需传 --conversation-id(openConversationId,单聊与群聊使用同一字段),默认置顶,传 --off 取消
- `chat message reply` 以当前用户身份引用回复,与 `chat message send` 的用户身份发送语义一致
- `chat message reply` 以当前用户身份引用回复,与 `chat message send` 的用户身份发送语义一致;**同样支持 `--ai-tag`(默认 true,默认带「通过AI发送」角标,传 `--ai-tag=false` 关闭)**(详见上文 send 段的「AI 代发标记」规则)
- **如何获取 openConversationId**(如果上层已有则直接使用,不必再查):
- 群聊:`dws chat search --query "群名"`
- 单聊:`dws chat conversation-info --user <userId>` 或 `dws chat conversation-info --open-dingtalk-id <openDingTalkId>`(人员信息可通过 `dws aisearch person --keyword "姓名" --dimension name` 获取)
@@ -1553,5 +1542,5 @@ Flags:
## 相关产品
- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、send-by-bot --users、send-by-bot --at-user-ids、list-by-sender --sender-user-id;获取 openDingTalkId 用于 message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot --open-dingtalk-ids、send-by-bot --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id
- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-by-sender 的 --sender-open-dingtalk-id
- [drive](./drive.md) — 上传文件获取下载链接,用于 Markdown 图片/文件消息
+13 -86
View File
@@ -1,7 +1,8 @@
# 通讯录 (contact) 命令参考
> **CRITICAL — 命令合法性**:contact 只有 `user` / `dept` / `label` / `relation` 四个二级子命令。
> **CRITICAL — 命令合法性**:contact 只有 `user` / `dept` / `relation` 三个可操作的二级子命令。
> 不存在 `contact search`、`contact find`、`contact list`、`contact get`、`contact user find/list`。
> 角色/标签(label)查询命令已下线,不再提供 `contact label list/get/list-members`。
> 构造命令前必须确认路径在下方「命令总览」中存在;不确定时,**根据意图对照下方「意图判断」选择正确命令**。
>
> **CRITICAL — 根部门**:钉钉根部门 `deptId=1`。`dept` 系列命令查根部门统一传 `--dept 1` 或 `--depts 1`,不要传 `self / me / root / 0`。
@@ -187,52 +188,6 @@ Notes:
- 跨层级成员展开见 [08-directory.md](../best_practices/08-directory.md) 的 `cross-level-dept-members` recipe
```
### label (角色查询)
> **角色ID = labelId**:用户提到"角色ID"时,均指通讯录 label 系统中的角色ID,**不是部门ID也不是userId**。查角色成员用 `label list-members --id <角色ID>`,不要传给 `dept get-info` 或 `user get`。
#### 获取企业所有角色列表
```
Usage:
dws contact label list
Example:
dws contact label list
Flags:
无
Notes:
- 无需参数,返回当前企业全部角色列表(labelId、labelName等)
- 用于不知道准确角色名称时先浏览全部角色
- 典型场景:用户说“企业所有主管/查所有管理员/财务人员有哪些”→ 先 label list 浏览全部角色,匹配目标角色后 label list-members 获取成员
```
#### 根据角色名称查询角色
```
Usage:
dws contact label get [flags]
Example:
dws contact label get --names "管理员"
dws contact label get --names "管理员,财务"
Flags:
--names string 角色名称,逗号分隔 (必填)
Notes:
- 精确匹配角色名称,不支持模糊搜索
- 支持同时查询多个角色名称,逗号分隔
- 无需分页
```
#### 查询角色下的成员
```
Usage:
dws contact label list-members [flags]
Example:
dws contact label list-members --id 12345
Flags:
--id string 角色 ID (必填)
Notes:
- 根据角色ID直接查询成员列表;已有角色ID时直接用 `--id <labelId>`
- 不知道角色ID时:先 `dws contact label get --names "角色名"` 或 `dws contact label list` 获取 labelId
```
## 意图判断
> **搜人首选 `aisearch person`**:凡是“找人/搜人/找同事/谁负责/上级/下级”均优先用 [aisearch person](./aisearch.md),以下场景才用 contact。
@@ -247,32 +202,19 @@ Notes:
用户说"部门详情/部门信息/部门多少人" → `dept get-info`(返回部门ID、部门名称、部门人数;需 deptId,若只有部门名称需先 `dept search`)
用户说"子部门/下设部门/部门有哪些下级部门/枚举二级部门" → `dept list-children`(需父 deptId;只有部门名先 `dept search`)
用户说"部门有谁/部门成员/人员名单" → `dept list-members`(需 deptId;**仅本部门不含下级**,含下级先 `dept list-children` 再合并查)
用户查询涵盖"角色"(主管/管理员/财务/HR/总经理等任意角色名)→ 统一走 `contact label` 链路,按下方决策树选命令:
- 不知道角色名 / 枚举所有角色 → `label list`
- 已知角色名,查ID或成员 → 先 `label get --names <名>` 拿ID,查成员再调 `label list-members --id <ID>`;**精确匹配无结果时降级 `label list` 模糊匹配**
- 已知角色ID 查成员 → `label list-members --id <ID>`
用户查询涵盖"角色/职责"(主管/管理员/财务/HR/总经理/谁负责 XX 等)→ 优先用 [`aisearch person`](./aisearch.md) 按职责维度找人(`dws aisearch person --keyword "<角色或职责>" --dimension duty`)。
> [!IMPORTANT]
> **角色查询 3 步决策树**(不依赖字串匹配,按语义判定):
> 1. 不知道角色名 / 要枚举所有角色(列出企业有哪些角色、每个角色的名称和ID、不确定叫什么角色、负责XX的人有哪些等) → `label list`
> 2. 已知角色名 要查角色ID → `label get --names`
> 3. 已知角色ID 要查成员 → `label list-members --id`
>
> 任何含"角色"一词的查询默认走 `contact label` 链路。**唯一例外**:终点是"某个人是否具备某权限"(如"张三是不是管理员")→ `user get`。禁止路由到 `user profile fields`(那是花名册字段)、`dept list-members` 筛选、或 OA/chat 模块。
> [!IMPORTANT]
> **角色查人 vs 查某人的角色信息 — 判断口径**:先判断用户的终点是"人"还是"属性":
> - 终点是**人**("管理员角色有哪些人""管理员下都有谁""查XX角色的成员")→ 角色维度查人,**必须**走 `contact label` 链路(`label get`/`label list` → `label list-members`),**禁止**通过 `dept list-members` 筛选 `isAdmin` 等字段替代
> **角色查人 vs 查某人的属性 — 判断口径**:先判断用户的终点是"人"还是"属性":
> - 终点是**人**("管理员有哪些人""谁负责财务""找 XX 角色的成员")→ 走 [`aisearch person`](./aisearch.md)(职责维度找人)
> - 终点是**属性**("张三是不是管理员""查某人的主管/管理员权限")→ 已知 userId 查个人详情,走 `user get`(返回 isAdmin/leader 等字段)
>
> 反例对照:
> - "管理员角色下都有哪些人" → `label get --names 管理员` → `label list-members`(终点=角色下的**人员列表**)
> - "我想知道管理员这个角色下都有谁" → `label get --names 管理员` → `label list-members`(终点=角色下的**人员列表**)
> - "管理员都有哪些人" → `aisearch person --keyword 管理员 --dimension duty`(终点=人员列表)
> - "张三是不是管理员" → `user get --ids <userId>`(终点=某个人的**属性**)
> - "查一下张三的管理员权限" → `user get --ids <userId>`(终点=某个人的**属性**)
> - "角色ID为55808858的角色下有哪些成员" → `label list-members --id 55808858`(已有角色ID直接查成员)
>
> **角色 = 通讯录 label**:用户提到"角色"(角色ID/角色成员/角色名称/企业角色/查角色下的人)时,均指通讯录组织角色,应走 `contact label` 链路。OA 审批只管审批流程(待审批/同意/拒绝),**不支持**查询角色成员;群角色(chat group-role)只管群内身份,不涉及企业组织角色。
> 注意:`contact label` 角色查询命令已下线,不要再构造 `contact label list/get/list-members`。OA 审批只管审批流程(待审批/同意/拒绝),**不支持**查询角色成员;群角色(chat group-role)只管群内身份,不涉及企业组织角色。
用户说"我关注了谁/我的特别关注列表/我的星标联系人/特别关注的人有哪些" → `relation list-my-followings`
> [!IMPORTANT]
@@ -312,28 +254,19 @@ dws contact dept list-children --dept <父deptId> --format json
# 6. 查看部门成员
dws contact dept list-members --depts <deptId> --format json
# 7. 获取企业所有角色列表 — 不知道角色名时先浏览
dws contact label list --format json
# 8. 根据角色名称查询角色
dws contact label get --names "管理员" --format json
# 9. 查询角色下的成员
dws contact label list-members --id <labelId> --format json
# 10. 查询花名册有权限的字段列表
# 7. 查询花名册有权限的字段列表
dws contact user profile fields --format json
# 11. 根据字段 code 查询指定员工的花名册信息
# 8. 根据字段 code 查询指定员工的花名册信息
dws contact user profile get --staff-id <STAFF_ID> --fields fieldCode1,fieldCode2 --format json
# 12. 查询所有可见字段的花名册信息
# 9. 查询所有可见字段的花名册信息
dws contact user profile get --staff-id <STAFF_ID> --format json
# 13. 查询全部离职员工
# 10. 查询全部离职员工
dws contact user dismission search --format json
# 14. 按姓名/时间范围/部门筛选离职员工
# 11. 按姓名/时间范围/部门筛选离职员工
dws contact user dismission search --name "张三" --format json
dws contact user dismission search --start 2026-01-01 --end 2026-03-31 --format json
dws contact user dismission search --depts 123456,789012 --hide-retirement=false --format json
@@ -347,8 +280,6 @@ dws contact user dismission search --depts 123456,789012 --hide-retirement=false
| `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) |
| `user get-self/search` | `userId` | profile get 的 --staff-id |
| `user profile fields` | `fieldCode` | profile get 的 --fields |
| `label list` | `labelId` / `labelName` | `label get --names` 或 `label list-members --id` |
| `label get` | `labelId` | `label list-members` 的 --id |
| `dept search/list-children` | `deptId` | dept get-info/list-children/list-members 的 --dept/--depts |
| `dept search/list-children` | `deptId` | dismission search 的 --depts |
@@ -362,11 +293,7 @@ dws contact user dismission search --depts 123456,789012 --hide-retirement=false
- 建议先执行 `user profile fields` 获取可用字段列表,再根据需要的字段 code 执行 `user profile get`
- `user dismission search` 的 `--start`/`--end` 必须同时设置或同时不设置,不允许只传其中一个
- `user dismission search` 默认隐藏退休人员(`--hide-retirement` 默认 true),默认展示合作伙伴(`--hide-partner` 默认 false)
- `label list` 无需参数,适用于不知道准确角色名称的场景;当用户说“查所有主管/主管理员/财务”等角色类型人员时,优先 `label list` 列出企业全部角色,LLM 灵活匹配目标角色后调用 `label list-members`
- 角色类查询(主管、管理员、财务、HR 等任意角色)优先走 label 链路,而非 dept list-members 或 aisearch person;label 精确命中角色维度,返回完整名单
- `label get` 是精确匹配角色名称,不支持模糊搜索;支持逗号分隔同时查询多个角色名称
- **`label get` 精确匹配无结果时的降级策略**:若 `label get --names "XX"` 返回空结果,必须降级调用 `label list` 获取全部角色列表,从中模糊匹配包含XX关键词的角色(如用户说"管理员"可匹配到"主管理员"和"子管理员"),再对匹配到的角色调用 `label list-members`
- `label list-members` 需要先通过 `label list` 或 `label get` 获取 labelId,再用 --id 查询角色下的成员
- 角色/职责类查询(主管、管理员、财务、HR 等任意角色,或"谁负责 XX")优先走 [`aisearch person`](./aisearch.md) 的职责维度(`--dimension duty`);`contact label` 角色查询命令已下线,不要再构造
## 自动化脚本
-27
View File
@@ -26,36 +26,10 @@ Flags:
--robot-code string 机器人 ID (必填, 或设 DINGTALK_DING_ROBOT_CODE)
```
### 查询 DING 消息历史
```
Usage:
dws ding message list [flags]
Example:
dws ding message list
dws ding message list --type UNREAD
dws ding message list --type SEND --cursor 10
Flags:
--cursor int 分页游标 (首次传 0, 翻页传返回的 nextCursor)
--type string 消息类型: ALL / UNREAD / SEND / NEW_COMMENT / DELETED (可选, 不传返回全部)
```
### 查看 DING 接收状态
```
Usage:
dws ding message receiver-status [flags]
Example:
dws ding message receiver-status --ding-id <OPEN_DING_ID>
# 查询 dingId: dws ding message list
Flags:
--ding-id string DING 消息 openDingId (必填)
```
## 意图判断
用户说"DING 一下/紧急通知/电话提醒" → `message send`
用户说"撤回 DING" → `message recall`
用户说"DING 消息/查 DING/DING 历史/我的 DING" → `message list`
用户说"DING 接收状态/谁收到了 DING/DING 已读" → `message receiver-status`
关键区分:
- ding(紧急提醒, 支持电话/短信) vs bot(常规群/单聊消息)
@@ -77,7 +51,6 @@ dws ding message recall --robot-code <ROBOT_CODE> --id <OPEN_DING_ID> --format j
| 操作 | 提取 | 用于 |
|------|------|------|
| `message send` | `openDingId` | message recall 的 --id |
| `message list` | `openDingId` | message receiver-status 的 --ding-id |
## 注意事项
- `--robot-code` 从钉钉开放平台 **应用管理 → 机器人** 中获取,也可设环境变量 `DINGTALK_DING_ROBOT_CODE`
- sms/call 类型有通信费用,使用前需和用户确认
+9 -9
View File
@@ -77,19 +77,19 @@ dws doc media --help
| `doc comment list/create/reply/create-inline` | 文档评论与划词评论 | `--node` (+ ...) | [`doc/doc-comment.md`](./doc/doc-comment.md) |
| `doc export` / `doc export get` | 在线文档导出 docx | `--node` `--output` | [`doc/doc-export.md`](./doc/doc-export.md) |
### 文件操作(已迁移,以下命令 deprecated)
### 文件操作(doc 与 drive 均提供,按场景选用)
> **迁移提示**:文件管理命令已按产品领域架构重新归属,旧命令在过渡期内仍可使用,运行时会输出 deprecated 警告。
> **说明**:以下命令在 `doc` 下均为可用命令;`drive` 下也有等价命令(聚合钉盘 + 文档空间)。需要纯文件/钉盘语义时用 `drive`,需要在文档空间内操作时用 `doc`,两者都不会报 deprecated。
| 旧命令 | 推荐命令 | 详见 |
|--------|----------|------|
| doc 命令 | drive 等价命令 | 详见 |
|----------|----------------|------|
| `doc upload` | `dws drive upload` | [`drive.md`](./drive.md) |
| `doc download` | `dws drive download` | [`drive.md`](./drive.md) |
| `doc copy` | `dws drive copy` | [`drive.md`](./drive.md) |
| `doc move` | `dws drive move` | [`drive.md`](./drive.md) |
| `doc rename` | `dws drive rename` | [`drive.md`](./drive.md) |
| `doc delete` | `dws drive delete` | [`drive.md`](./drive.md) |
| `doc folder create` | `dws drive folder create` | [`drive.md`](./drive.md) |
| `doc folder create` | `dws drive mkdir` | [`drive.md`](./drive.md) |
| `doc file create` | `dws wiki node create --type <type>` | [`wiki.md`](./wiki.md) |
| `doc permission *` | `dws drive permission *` | [`drive.md`](./drive.md) |
| `doc list` | `dws drive list --workspace` / `dws wiki node list` | [`drive.md`](./drive.md) / [`wiki.md`](./wiki.md) |
@@ -120,9 +120,9 @@ dws doc media --help
| 插入富 block(callout / 分栏 / 表格) | [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | block insert |
| 上传图片 / 附件 | [`doc-media.md`](./doc/doc-media.md) | media insert |
| 评论 / 划词评论(含 @人) | [`doc-comment.md`](./doc/doc-comment.md)(+ `dws contact user search` 取 mention 用 userId) | comment create |
| 文档分享 / 节点级权限 | [`drive.md`](./drive.md)(已迁移:`dws drive permission add/update/list/remove`) | `drive permission` |
| 文档分享 / 节点级权限 | [`drive.md`](./drive.md)(推荐 `dws drive permission add/update/list/remove`;`doc permission *` 亦可用) | `drive permission` |
| 导出 PDF / DOCX | [`doc-info.md`](./doc/doc-info.md) + [`doc-export.md`](./doc/doc-export.md) | export |
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`drive.md`](./drive.md)(已迁移:`dws drive upload/download/copy/move/rename/delete`) | `drive *` |
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`drive.md`](./drive.md)(推荐 `dws drive upload/download/copy/move/rename/delete`;同名 `doc` 命令亦可用) | `drive *` |
## 意图判断
@@ -284,12 +284,12 @@ dws doc block list --node <DOC_ID> --format json # 划词需先
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> \
--start 0 --end 10 --content "建议调整" --selected-text "原文" --format json
# ── 工作流 8: 权限授予(节点级,已迁移到 drive)──
# ── 工作流 8: 权限授予(节点级,推荐用 drive,doc permission 亦可用)──
dws contact user search --query "张三" --format json # 取 userId
dws drive permission add --node <DOC_ID> --user <uid1>,<uid2> --role EDITOR --format json
dws drive permission list --node <DOC_ID> --format json # 校验
# ── 工作流 9: 文件操作(已迁移到 drive)──
# ── 工作流 9: 文件操作(推荐用 drive,同名 doc 命令亦可用)──
# 第一步:获取 nodeId
# 方式 A(优先):用户直接提供 URL / nodeId → 直接传 --node
# 方式 B:按关键字找:dws drive search --query "项目周报" --format json
@@ -15,11 +15,11 @@ Usage:
Example:
dws doc comment list --node <DOC_ID>
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
dws doc comment list --node <DOC_ID> --limit 20 --cursor <TOKEN>
dws doc comment list --node <DOC_ID> --page-size 20 --next-token <TOKEN>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--limit int 每页返回的评论数量,默认 50,最大 50
--cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
--page-size int 每页返回的评论数量
--next-token string 下一页 token,从上一次请求的返回结果中获取 (首次请求不传)
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
```
@@ -13,19 +13,19 @@ Usage:
Example:
dws doc list
dws doc list --folder <DOC_FOLDER_NODE_ID>
dws doc list --workspace <WS_ID> --limit 20
dws doc list --workspace <WS_ID> --page-size 20
Flags:
--folder string 文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 知识库 ID
--limit int 每页数量 (默认 50,最大 50)
--cursor string 分页游标 (从上次结果的 nextPageToken 获取)
--page-size int 每页数量
--page-token string 分页 token (从上次结果的 nextPageToken 获取)
```
## 关键说明
- 不传任何 flag 时遍历"我的文档"根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 单页最多 50 条,需翻页时使用 `nextPageToken` → `--cursor`。
- 需翻页时使用 `nextPageToken` → `--page-token`。
## 上下文传递
@@ -45,10 +45,10 @@ dws doc list --folder <DOC_FOLDER_NODE_ID> --format json
dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# 浏览指定知识库根目录(取出 workspaceId 后)
dws doc list --workspace <WS_ID> --limit 20 --format json
dws doc list --workspace <WS_ID> --page-size 20 --format json
# 翻页
dws doc list --folder <DOC_FOLDER_NODE_ID> --cursor <nextPageToken> --format json
dws doc list --folder <DOC_FOLDER_NODE_ID> --page-token <nextPageToken> --format json
```
## 参考
@@ -31,8 +31,8 @@ Flags:
--editor-uids strings 按编辑者用户 ID 过滤,逗号分隔
--mentioned-uids strings 按 @提及的用户 ID 过滤,逗号分隔
--workspace-ids strings 按知识库 ID 过滤,支持知识库 URL,逗号分隔
--limit int 每页数量 (默认 10,最大 30)
--cursor string 分页游标 (从上次结果的 nextPageToken 获取)
--page-size int 每页数量
--page-token string 分页 token (从上次结果的 nextPageToken 获取)
```
## 关键说明
@@ -70,7 +70,7 @@ dws doc search --creator-uids uid1,uid2
dws doc search --workspace-ids wsId1,wsId2
# 翻页
dws doc search --query "周报" --limit 30 --cursor <nextPageToken>
dws doc search --query "周报" --page-size 30 --page-token <nextPageToken>
```
## 参考
+36 -36
View File
@@ -96,13 +96,13 @@ dws contact user search --keyword "姓名" --format json
Usage:
dws mail message search [flags]
Example:
dws mail message search --email user@company.com --query "subject:\"周报\"" --size 20
dws mail message search --email user@company.com --query "from:alice AND date>2025-06-01T00:00:00Z" --size 10
dws mail message search --email user@company.com --query "subject:\"周报\"" --limit 20
dws mail message search --email user@company.com --query "from:alice AND date>2025-06-01T00:00:00Z" --limit 10
Flags:
--cursor string 邮件的起始偏移标识, 其值取自响应中的nextCursor字段。""表示从头开始
--email string 搜索目标邮箱地址 (必填)
--query string KQL 查询表达式 (必填), 其中 date 格式需遵循 ISO8601 规范
--size string 每页返回数量(最大限制 100, 默认 20),别名: --limit, --page-size
--limit string 每页返回数量(最大限制 100, 默认 20)
```
KQL 查询字段: date, size, tag, folderId, isRead, hasAttachments, subject, attachname, body, from, to
@@ -140,9 +140,9 @@ KQL 查询字段: date, size, tag, folderId, isRead, hasAttachments, subject, at
**翻页示例:**
```bash
# 第一页
dws mail message search --email user@company.com --query "folderId:2" --size 20 --format json
dws mail message search --email user@company.com --query "folderId:2" --limit 20 --format json
# 取返回中的 nextCursor,传入下一次请求(nextCursor="$" 时停止)
dws mail message search --email user@company.com --query "folderId:2" --size 20 --cursor <nextCursor> --format json
dws mail message search --email user@company.com --query "folderId:2" --limit 20 --cursor <nextCursor> --format json
```
### 查看邮件完整内容
@@ -168,17 +168,17 @@ Usage:
dws mail message send [flags]
Example:
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "本周完成任务A和任务B"
--subject "周报" --content "本周完成任务A和任务B"
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "见附件" --attachment ./report.pdf
--subject "周报" --content "见附件" --attachment ./report.pdf
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "见附件" --attachment ./a.pdf --attachment ./b.xlsx
--subject "周报" --content "见附件" --attachment ./a.pdf --attachment ./b.xlsx
dws mail message send --from user@company.com --to colleague@company.com \
--subject "图表周报" --body "图表如下:[inline:chart.png]" --inline-attachment ./chart.png
--subject "图表周报" --content "图表如下:[inline:chart.png]" --inline-attachment ./chart.png
dws mail message send --from user@company.com --to colleague@company.com \
--subject "带图文档" --body "见附件,图表:[inline:img.png]" --attachment ./doc.pdf --inline-attachment ./img.png
--subject "带图文档" --content "见附件,图表:[inline:img.png]" --attachment ./doc.pdf --inline-attachment ./img.png
Flags:
--body string 邮件正文 (必填)
--content string 邮件正文 (必填)
--cc string 抄送人列表
--from string 发件人邮箱 (必填),别名: --sender
--subject string 邮件标题 (必填)
@@ -202,7 +202,7 @@ Flags:
- 仅支持图片类型:`jpg` / `jpeg` / `png` / `gif` / `webp` / `bmp` / `svg`
- CLI 自动生成 contentId,格式:`inline-{文件名(不含扩展名)}-{序号}@alimail.com`,例:`inline-chart-1@alimail.com`
- 在 `--body` 中使用占位符 `[inline:文件名]` 引用图片,CLI 自动替换为 `<img src="cid:...">` 标签
- 在 `--content` 中使用占位符 `[inline:文件名]` 引用图片,CLI 自动替换为 `<img src="cid:...">` 标签
- 若 body 中没有对应占位符,内联图片会自动追加到正文末尾
- 非图片类型(PDF、视频、音频等)请改用 `--attachment`
@@ -339,13 +339,13 @@ Usage:
dws mail message reply [flags]
Example:
dws mail message reply --from user@company.com --id <messageId>
dws mail message reply --from user@company.com --id <messageId> --subject "Re: 周报" --body "已收到,谢谢!"
dws mail message reply --from user@company.com --id <messageId> --subject "Re: 周报" --content "已收到,谢谢!"
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--to string 收件人列表(可选)
--id string 要回复的邮件 ID (必填)
--subject string 回复邮件标题(可选)
--body string 回复正文(可选)
--content string 回复正文(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
@@ -371,13 +371,13 @@ Usage:
dws mail message reply-all [flags]
Example:
dws mail message reply-all --from user@company.com --id <messageId>
dws mail message reply-all --from user@company.com --id <messageId> --subject "Re: 周报" --body "感谢大家的参与!"
dws mail message reply-all --from user@company.com --id <messageId> --subject "Re: 周报" --content "感谢大家的参与!"
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--to string 收件人列表(可选,包含发件人及所有原始收件人)
--id string 要回复的邮件 ID (必填)
--subject string 回复邮件标题(可选)
--body string 回复正文(可选)
--content string 回复正文(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
@@ -409,7 +409,7 @@ Flags:
--to string 转发收件人列表(可选)
--id string 要转发的邮件 ID (必填)
--subject string 转发邮件标题(可选)
--body string 转发附言(可选)
--content string 转发附言(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
@@ -461,27 +461,27 @@ Usage:
dws mail draft create [flags]
Example:
dws mail draft create --from user@company.com --to colleague@company.com \
--subject "草稿标题" --body "草稿正文"
--subject "草稿标题" --content "草稿正文"
dws mail draft create --from user@company.com --subject "草稿标题"
dws mail draft create --from user@company.com --subject "带附件草稿" \
--body "见附件" --attachment ./report.pdf
--content "见附件" --attachment ./report.pdf
dws mail draft create --from user@company.com --subject "带图片草稿" \
--body "图表:[inline:chart.png]" --inline-attachment ./chart.png
--content "图表:[inline:chart.png]" --inline-attachment ./chart.png
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--subject string 邮件标题 (必填)
--to string 收件人列表(可选,有确定收件人时才传)
--cc string 抄送人列表(可选,有确定抄送人时才传)
--body string 邮件正文(可选,有正文内容时才传)
--content string 邮件正文(可选,有正文内容时才传)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
> **注意:** `--to`、`--cc`、`--body` 均为可选参数,**仅在用户明确提供对应信息时才传入**。若用户未指定收件人,不要传 `--to ""`(空字符串)。
> **注意:** `--to`、`--cc`、`--content` 均为可选参数,**仅在用户明确提供对应信息时才传入**。若用户未指定收件人,不要传 `--to ""`(空字符串)。
**附件说明:**
指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动完成草稿创建和附件上传,**草稿保留在草稿箱,不会发送**。内联图片用法同 `message send`(`--body` 中使用 `[inline:文件名]` 占位符)。
指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动完成草稿创建和附件上传,**草稿保留在草稿箱,不会发送**。内联图片用法同 `message send`(`--content` 中使用 `[inline:文件名]` 占位符)。
**返回字段:**
@@ -494,24 +494,24 @@ Flags:
Usage:
dws mail draft update [flags]
Example:
dws mail draft update --from user@company.com --id <messageId> --subject "新标题" --body "新正文"
dws mail draft update --from user@company.com --id <messageId> --body "见附件" --attachment ./report.pdf
dws mail draft update --from user@company.com --id <messageId> --subject "新标题" --content "新正文"
dws mail draft update --from user@company.com --id <messageId> --content "见附件" --attachment ./report.pdf
dws mail draft update --from user@company.com --id <messageId> \
--body "图表:[inline:chart.png]" --inline-attachment ./chart.png
--content "图表:[inline:chart.png]" --inline-attachment ./chart.png
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--id string 草稿邮件 ID (必填)
--to string 收件人列表(可选)
--cc string 抄送人列表(可选)
--subject string 邮件标题(可选)
--body string 邮件正文(可选)
--content string 邮件正文(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
**附件说明:**
指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动完成草稿更新和附件上传,**草稿保留在草稿箱,不会发送**。内联图片用法同 `message send`(`--body` 中使用 `[inline:文件名]` 占位符)。
指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动完成草稿更新和附件上传,**草稿保留在草稿箱,不会发送**。内联图片用法同 `message send`(`--content` 中使用 `[inline:文件名]` 占位符)。
### 发送草稿
```
@@ -533,13 +533,13 @@ Usage:
Example:
dws mail user search --keyword "张三"
dws mail user search --email user@company.com --keyword "张三"
dws mail user search --email user@company.com --keyword "alice" --size 10
dws mail user search --email user@company.com --keyword "alice" --limit 10
dws mail user search --email user@company.com --keyword "alice" --cursor <nextCursor>
Flags:
--email string 搜索目标邮箱地址 (可选)
--keyword string 搜索关键词 (必填)
--cursor string 分页游标,取自响应中的 nextCursor 字段(可选)
--size string 每页返回数量(可选)
--limit string 每页返回数量(可选)
```
> **重要区别:**
@@ -621,29 +621,29 @@ dws mail mailbox list --format json
# 2. 搜索邮件 — 提取 messageId
dws mail message search --email user@company.com \
--query "subject:\"周报\" AND date>2025-06-01T00:00:00Z" --size 10 --format json
--query "subject:\"周报\" AND date>2025-06-01T00:00:00Z" --limit 10 --format json
# 3. 查看邮件详情
dws mail message get --email user@company.com --id <messageId> --format json
# 4. 发送邮件(纯文本)
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "本周完成…" --format json
--subject "周报" --content "本周完成…" --format json
# 4b. 发送带附件的邮件(自动编排:创建草稿→上传附件→发送草稿)
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "见附件" --attachment ./report.pdf --format json
--subject "周报" --content "见附件" --attachment ./report.pdf --format json
# 4c. 发送带内联图片的邮件(正文自动转 HTML,<img> 标签自动注入)
dws mail message send --from user@company.com --to colleague@company.com \
--subject "图表周报" --body "本周图表如下:[inline:chart.png]" \
--subject "图表周报" --content "本周图表如下:[inline:chart.png]" \
--inline-attachment ./chart.png --format json
# 5. 下载邮件附件到本地(每次只能下载一个附件,不支持批量下载)
# 步骤 5.1:搜索匹配的邮件,获取 messageId 列表
# 示例:下载4月所有发票邮件的附件
dws mail message search --email user@company.com \
--query "subject:发票 AND date>2025-04-01T00:00:00Z AND date<2025-05-01T00:00:00Z AND hasAttachments:true" --size 50 --format json
--query "subject:发票 AND date>2025-04-01T00:00:00Z AND date<2025-05-01T00:00:00Z AND hasAttachments:true" --limit 50 --format json
# 步骤 5.2:对每封邮件,列出附件获取 attachmentId 和 name
# (对搜索结果中的每封邮件都要执行一次)
@@ -656,7 +656,7 @@ dws mail attachment download --email user@company.com \
# 5. 获取邮件所属会话详情(thread)
# 步骤 5.1:先通过 message search 或 message get 获取邮件中的 conversationId
dws mail message search --email user@company.com \
--query "subject:\"周报\"" --size 5 --format json
--query "subject:\"周报\"" --limit 5 --format json
# 从返回的邮件列表中提取 conversationId 字段
# 步骤 5.2:用 conversationId 获取会话详情
+28 -28
View File
@@ -22,9 +22,9 @@ minutes 模块的命令是**两级或三级结构**,不同层级之间不能
```
dws minutes
├── list <scope> # 查询听记列表(list 后必须跟 mine / shared / all)
│ ├── mine [--query] [--start] [--end] [--max] [--next-token] # 仅查询我自己创建/发起的听记(不含他人共享给我的)
│ ├── shared [--query] [--start] [--end] [--max] [--next-token] # 仅查询他人共享给我的听记(不含我自己创建的)
│ └── all [--query] [--start] [--end] [--max] [--next-token] # 查询我可访问的所有听记(= mine ∪ shared,覆盖面最广,默认 scope)
│ ├── mine [--query] [--start] [--end] [--max] [--cursor] # 仅查询我自己创建/发起的听记(不含他人共享给我的)
│ ├── shared [--query] [--start] [--end] [--max] [--cursor] # 仅查询他人共享给我的听记(不含我自己创建的)
│ └── all [--query] [--start] [--end] [--max] [--cursor] # 查询我可访问的所有听记(= mine ∪ shared,覆盖面最广,默认 scope)
├── get <subcommand> # 获取听记详情(get 后必须跟子命令名)
│ ├── info --id <uuid> # 获取听记基础信息(标题、时长、参与人、状态等)
│ ├── summary --id <uuid> # 获取听记 AI 摘要(结构化纪要)
@@ -102,15 +102,15 @@ dws minutes
>
> | 语义 | 规约 Primary | minutes 当前 CLI 实际参数 | alias 注册状态 | 说明 |
> |------|-------------|------------------------|--------------|------|
> | 单页大小 (Group 13) | `--limit` | `--max` | `--limit` 未注册 | minutes 用 `--max`,其他模块用 `--limit`。**跨模块切换时注意差异** |
> | 续页标识 (Group 14) | `--cursor` | `--next-token` | `--next-token` 在 alias 池中 | minutes 用 `--next-token`,合法 alias |
> | 单页大小 (Group 13) | `--limit` | `--limit`(别名 `--max`) | 已对齐 | `list mine/shared/all` 用 `--limit`,`--max` 为别名 |
> | 续页标识 (Group 14) | `--cursor` | `list`: `--cursor`;`get transcription`: `--next-token` | 部分对齐 | **list 系列用 `--cursor`,get transcription 用 `--next-token`,不要混用** |
> | 搜索关键词 (Group 11) | `--query` | `--query` | 已对齐 | 无差异 |
> | 起始时间 (Group 15) | `--start` | `--start` | 已对齐 | 无差异 |
> | 结束时间 (Group 16) | `--end` | `--end` | 已对齐 | 无差异 |
>
> **跨模块参数名差异速查(防止混用):**
> - 分页大小:minutes 用 `--max`,aitable/calendar/chat/drive 用 `--limit`
> - 续页标识:minutes 用 `--next-token`,aitable/drive/doc 用 `--cursor`
> - 分页大小:minutes `list` 用 `--limit`(别名 `--max`),与其他模块一致
> - 续页标识:minutes `list mine/shared/all` 用 `--cursor`;minutes `get transcription` 用 `--next-token`;aitable/drive/doc 用 `--cursor`
> - 起止时间:minutes/report 用 `--start`/`--end`,calendar 用 `--start-time`/`--end-time`(跨模块最高频错误)
**铁律:禁止编造 taskUuid(0512 P0 Golden Case 提炼)**
@@ -271,9 +271,9 @@ dws minutes
```
dws minutes
├── list <scope> # 二级:list 后必须跟 mine / shared / all
│ ├── mine [--query] [--start] [--end] [--max] [--next-token]
│ ├── shared [--query] [--start] [--end] [--max] [--next-token]
│ └── all [--query] [--start] [--end] [--max] [--next-token]
│ ├── mine [--query] [--start] [--end] [--max] [--cursor]
│ ├── shared [--query] [--start] [--end] [--max] [--cursor]
│ └── all [--query] [--start] [--end] [--max] [--cursor]
├── get <subcommand> # 二级:get 后必须跟子命令名
│ ├── info --id <uuid>
│ ├── summary --id <uuid>
@@ -320,17 +320,17 @@ Usage:
Example:
dws minutes list mine
dws minutes list mine --max 10
dws minutes list mine --max 10 --next-token <nextToken>
dws minutes list mine --max 10 --cursor <nextCursor>
dws minutes list mine --query "周会"
Flags:
--max float 查询的听记篇数 (默认 10)
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
--max float 查询的听记篇数 (默认 10,--limit 的别名)
--cursor string 分页游标 (首页留空,后续填写前次返回的 nextCursor)
--query string 关键字筛选 (可选)
--start string 开始时间 ISO-8601 (可选)
--end string 结束时间 ISO-8601 (可选)
```
查询我创建的听记列表,支持 `--max` 和 `--next-token` 分页,支持按关键字和时间范围筛选。
查询我创建的听记列表,支持 `--max` 和 `--cursor` 分页,支持按关键字和时间范围筛选。
### 查询他人共享给我的听记列表
```
@@ -339,16 +339,16 @@ Usage:
Example:
dws minutes list shared
dws minutes list shared --max 20
dws minutes list shared --max 5 --next-token <nextToken>
dws minutes list shared --max 5 --cursor <nextCursor>
Flags:
--max float 查询的听记篇数 (默认 10)
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
--max float 查询的听记篇数 (默认 10,--limit 的别名)
--cursor string 分页游标 (首页留空,后续填写前次返回的 nextCursor)
--query string 关键字筛选 (可选)
--start string 开始时间 ISO-8601 (可选)
--end string 结束时间 ISO-8601 (可选)
```
查询他人共享给我的听记列表,支持 `--max` 和 `--next-token` 分页,支持按关键字和时间范围筛选。
查询他人共享给我的听记列表,支持 `--max` 和 `--cursor` 分页,支持按关键字和时间范围筛选。
### 查询我有权限访问的所有听记列表
```
@@ -359,16 +359,16 @@ Example:
dws minutes list all --max 20
dws minutes list all --query "周会" --max 20
dws minutes list all --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00"
dws minutes list all --max 10 --next-token <nextToken>
dws minutes list all --max 10 --cursor <nextCursor>
Flags:
--end string 结束时间 ISO-8601 (可选)
--query string 关键字筛选 (可选)
--max float 查询的听记篇数 (默认 10)
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
--max float 查询的听记篇数 (默认 10,--limit 的别名)
--cursor string 分页游标 (首页留空,后续填写前次返回的 nextCursor)
--start string 开始时间 ISO-8601 (可选)
```
查询我有权限访问的所有听记列表(包括我创建的、他人共享给我的等所有有权限的听记)。支持按关键字和时间范围筛选。时间范围和关键字为可选参数,不传则返回所有有权限的听记。支持使用 `--max` 和 `--next-token` 进行分页查询。
查询我有权限访问的所有听记列表(包括我创建的、他人共享给我的等所有有权限的听记)。支持按关键字和时间范围筛选。时间范围和关键字为可选参数,不传则返回所有有权限的听记。支持使用 `--max` 和 `--cursor` 进行分页查询。
### 获取听记基础信息
```
@@ -1607,7 +1607,7 @@ dws minutes record start --format json
# 1. 查看我的听记列表 — 提取 taskUuid
dws minutes list mine --format json
dws minutes list mine --max 10 --next-token <nextToken> --format json
dws minutes list mine --max 10 --cursor <nextCursor> --format json
dws minutes list mine --query "周会" --format json
# 1b. 查看共享给我的听记
@@ -1678,9 +1678,9 @@ dws minutes upload cancel --session-id <sessionId> --format json
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list mine` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `list shared` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `list all` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `list mine` | `taskUuid`、`nextCursor` | get/update 的 --id;翻页时 --cursor |
| `list shared` | `taskUuid`、`nextCursor` | get/update 的 --id;翻页时 --cursor |
| `list all` | `taskUuid`、`nextCursor` | get/update 的 --id;翻页时 --cursor |
| `get batch` | 各听记 `taskUuid` | 进一步查询详情 |
| `get audio` | 音频/视频 OSS 地址 | 用 HTTP GET 下载录音文件 / 在浏览器播放 |
| `record start` | `taskUuid`/`uuid` | record pause/resume/stop 的 --id |
@@ -1841,7 +1841,7 @@ loop:
- `record pause` / `record resume` / `record stop` 对应 `cmd=pause/resume/end`,需要传入 `--id`(映射 MCP 入参 `uuid`)
- 如果用户传入听记 URL(格式: `https://shanji.dingtalk.com/app/transcribes/<taskUuid>`),直接从路径末段提取 taskUuid 作为 `--id` 参数,无需再调用 list 查询
- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`)
- 三个 list 命令均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选
- 三个 list 命令均支持 `--max`(=--limit)、`--cursor` 分页及 `--query`、`--start`、`--end` 筛选
- `list mine`、`list shared` 默认每页 20 条,`list all` 默认每页 10 条
- `get summary` 返回 AI 生成的结构化 Markdown 摘要
- `get transcription` 的 `--direction` 控制时间排序: 0=正序(默认), 1=倒序;当用户明确要求查看/分析转写原文时,默认自动翻页拉取全部原文(不需要用户手动说"拉第一页"),如果用户意图不是专门看原文(如查列表、看摘要),则不应主动调用此命令
@@ -1860,7 +1860,7 @@ loop:
- 当用户提供关键词或时间范围时,必须使用 --query / --start / --end 在服务端筛选,严禁全量拉取后本地过滤。**严禁在 dws 命令后拼接 shell 管道做本地过滤**(如 `| grep "关键词"` / `| head -50` / `python -c "import json;..."` / `> /tmp/xxx.txt && grep ...`),这些写法在 Windows 沙箱里 100% 失败(`head`/`grep`/`wait` 不是 Windows 内置命令),即使在 macOS 上也会因 list 本身失败导致整个管道链路断掉。正确做法是始终使用 `--query` / `--start` / `--end` / `--max` 等 cli 内置参数在服务端完成筛选
- `dws minutes list` 后面**必须跟 `mine` / `shared` / `all` 子命令**,不能直接 `dws minutes list --start ... --end ...`(会报 `unknown flag: --start`)。正确写法:`dws minutes list mine --start ... --end ...` 或 `dws minutes list all --query "关键词" --start ... --end ...`
- get transcription 翻页时,同一个 next-token 连续返回空 2 次即终止翻页,不再重试
- **跨平台兼容性**:悟空运行环境可能是 Windows cmd / PowerShell / macOS bash,**严禁在 dws 命令中使用任何依赖特定 shell 的工具或管道**,包括但不限于:`| head`、`| grep`、`| tail`、`| wc`、`& wait`、`> %TEMP%\xxx`、`timeout /t 5`、`Start-Sleep`、`python -c "..."` 等。所有数据筛选、截断、过滤都必须通过 dws cli 自身的参数完成(`--query`/`--start`/`--end`/`--max`/`--next-token`)
- **跨平台兼容性**:悟空运行环境可能是 Windows cmd / PowerShell / macOS bash,**严禁在 dws 命令中使用任何依赖特定 shell 的工具或管道**,包括但不限于:`| head`、`| grep`、`| tail`、`| wc`、`& wait`、`> %TEMP%\xxx`、`timeout /t 5`、`Start-Sleep`、`python -c "..."` 等。所有数据筛选、截断、过滤都必须通过 dws cli 自身的参数完成(`--query`/`--start`/`--end`/`--max`/`--cursor`,get transcription 翻页用 `--next-token`)
### 错误恢复策略(AI 遇到以下错误时必须自动执行对应恢复动作)
+1 -1
View File
@@ -302,7 +302,7 @@ Flags:
- 用户说"复制知识库里的文档" → `node copy`(需 `--workspace` + `--node`)
- 用户说"移动知识库里的文档" → `node move`(需 `--workspace` + `--node`)
- 用户说"删除知识库里的文档/节点" → `node delete`(需 `--workspace` + `--node`)
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--user` + `--role`)
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--users` + `--role`)
- 用户说"修改某人在知识库的权限/调整成员角色" → `member update`
- 用户说"移除知识库成员/把某人从知识库移除/删除知识库成员" → `member remove`(需 `--workspace` + `--users`)
- 用户说"知识库有哪些成员/查看知识库成员" → `member list`
+2
View File
@@ -23,6 +23,8 @@ metadata:
> 命令参考:[aisearch.md](references/aisearch.md)。
> 跨组织:当前组织搜不到人时,别判定「查无此人」——先 `dws profile list` 看有哪些已登录组织,再对每个组织带 `--profile <corpId>` 各搜一遍,全无才追问用户。详见 `dingtalk-profile` skill。
## 开放平台文档 RAG / 错误码排查
- 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。
+2
View File
@@ -23,6 +23,8 @@ metadata:
> 命令参考:[chat.md](references/chat.md);表情:[chat-emoji-list.md](references/chat-emoji-list.md);剧本:[01-messaging.md](references/01-messaging.md)。
> 跨组织:当前组织搜不到群 / 单聊时,别判定「不存在」——先 `dws profile list` 看有哪些已登录组织,再对每个组织带 `--profile <corpId>` 各搜一遍,全无才追问用户。详见 `dingtalk-profile` skill。
## 开放平台文档 RAG / 错误码排查
- 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。
+2
View File
@@ -23,6 +23,8 @@ metadata:
> 命令参考:[contact.md](references/contact.md);剧本:[08-directory.md](references/08-directory.md)。
> 跨组织:当前组织查不到人时,别判定「查无此人」——先 `dws profile list` 看有哪些已登录组织,再对每个组织带 `--profile <corpId>` 各查一遍,全无才追问用户。详见 `dingtalk-profile` skill。
## 开放平台文档 RAG / 错误码排查
- 任何产品执行中,只要用户问开放平台 API、接口参数、字段含义、权限点、回调、SDK、配额、错误码,或命令返回上游 OpenAPI/SDK 错误,必须先用 `dws devdoc article search --query "<关键词>" --format json` 做官方文档 RAG。
+48
View File
@@ -0,0 +1,48 @@
---
name: dingtalk-profile
description: 钉钉多组织 / profile 管理与跨组织取数。Use when 用户说 切换组织/换组织/跨组织/另一个钉钉/别的公司/多组织/看登录了哪些组织/profile,或在当前组织找不到群/人/数据需要去其他组织找。命令前缀:dws profile / dws auth / 全局 --profile。
cli_version: ">=1.0.40"
metadata:
category: product
stability: experimental
requires:
bins:
- dws
---
# 钉钉多组织 / profile Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准;接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
<!-- SAFETY_PREAMBLE_INJECT -->
dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 按当前 profile 自动注入)。
## 触发条件(命中任一即用本 skill)
- 显式:用户提到 切换 / 换 / 跨组织、另一个钉钉、别的公司、看登录了哪些组织、当前是哪个组织、某人 / 某群 / 某数据在别的组织
- 隐式(最常见、易漏):在当前组织读 / 搜没找到目标(群 / 人 / 数据),且 `dws profile list` 显示已登录 ≥2 个组织 —— 别急着判「不存在」,按下方跨组织铁律去其他组织找
- 需要跨多个组织汇总 / 对比数据
- 用户问认证状态 / 登录了哪些组织 / 主组织是哪个
**不触发**:只登录 1 个组织时,按当前组织正常处理,不带 `--profile`。
## 命令
- `dws profile list` — 列出已登录组织(主 / 当前标记、状态、有效期),只读元数据
- `dws profile switch <名称|corpId|->` — 持久切换当前组织;`-` 切回上一个;无参数在交互终端弹选择器(非交互须显式传参)。`dws profile use` 是别名
- 全局 `--profile <名称|corpId>` — 单次指定本命令用哪个组织,一次性、不改当前组织
- `dws auth login` — 再登一个组织即新增 profile(自动从授权账号取 corpId / corpName);同组织重复 login = 刷新
- `dws auth status [--profile <名称>]` — 查看认证状态
## 跨组织铁律(必须执行,不得跳过)
「找群 / 找人 / 找数据」(chat search、aisearch / contact、doc / wiki 搜索等读 / 搜场景)在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile <corpId>` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。
## 跨组织聚合(agent 编排,无内置 --all-orgs)
① `dws profile list` 拿到所有已登录组织 → ② 对每个组织带 `--profile <corpId>` 各取一次数 → ③ 合并并标注来源组织;某组织失败则标「该组织暂不可用」并继续返回其余。
## 安全护栏(务必遵守)
- 只有 `dws profile list` 显示 ≥2 个组织才启用跨组织逻辑;单组织直接按当前组织走,不带 `--profile`。
- 自动跨组织只对「读 / 搜」。写 / 发 / 删 / 撤回等操作默认只在当前组织做;确需带 `--profile` 跨组织写时,必须先与用户确认目标组织。
- 持久切换 `dws profile switch`(改默认组织)按写操作对待:未经用户明确要求不得执行。跨组织找数一律用一次性 `--profile`,不改当前组织。
- `dws auth logout` 默认退出所有已登录组织;只退一个加 `--profile <名称|corpId>`。退主组织不会被拦截,会静默改选新主,执行前必须向用户确认。
+7 -7
View File
@@ -35,17 +35,17 @@ metadata:
| 用户说 | 命令 |
|--------|------|
| "今天收到的日志" | `python scripts/report_received_today.py` |
| "看日志模版" | `dws report template list` → `dws report template detail --name "<模版名>"` |
| "提交日报 / 周报(按模版)" | `dws report create --template-id <id> --contents '[...]' |
| "我已发送的日志" | `dws report sent --start <ISO> --end <ISO>` |
| "日志已读统计" | `dws report stats --report-id <id>` |
| "看日志模版" | `dws report template list` → `dws report template get --name "<模版名>"` |
| "提交日报 / 周报(按模版)" | `dws report entry submit --template-id <id> --contents-file <tmp>.json` |
| "我已发送的日志" | `dws report outbox list --start <ISO> --end <ISO>` |
| "日志已读统计" | `dws report entry stats --report-id <id>` |
| "生成日报 / 周报 / 月报 / 主题报告" | 见 [05-reporting.md](references/05-reporting.md) recipe |
## 日志查询硬约束
- 查“收到的日志”必须用 `dws report list --start "<ISO>" --end "<ISO>" --cursor 0 --size 20 --format json`,并把“今天 / 最近 30 天”等时间词先展开成完整 ISO 起止时间。
- 列表返回后,后续 `detail` / `stats` 必须复用同一个 `reportId`;不要重新挑选、猜测或改用标题。
- 用户要正文时用 `dws report detail --report-id <reportId>`;用户要已读/统计时用 `dws report stats --report-id <reportId>`。
- 查“收到的日志”必须用 `dws report inbox list --start "<ISO>" --end "<ISO>" --cursor 0 --size 20 --format json`,并把“今天 / 最近 30 天”等时间词先展开成完整 ISO 起止时间。
- 列表返回后,后续 `entry get` / `entry stats` 必须复用同一个 `reportId`;不要重新挑选、猜测或改用标题。
- 用户要正文时用 `dws report entry get --report-id <reportId>`;用户要已读/统计时用 `dws report entry stats --report-id <reportId>`。
## 跨产品协作
@@ -8,7 +8,7 @@
| query 中是否含强信号 | 选哪个 recipe | 默认值 |
|---------------------|---------------|--------|
| 含「钉钉日志 / OA 周报 / 我的钉钉日志 / 日报模板 / 周报模板 / 提交日志 / 填模版」 | `submit-report`(走 dws report create) | — |
| 含「钉钉日志 / OA 周报 / 我的钉钉日志 / 日报模板 / 周报模板 / 提交日志 / 填模版」 | `submit-report`(走 dws report entry submit) | — |
| 含「在线文档 / 写一篇文档 / 整理成文档 / 文档保存」 | `generate-*-report`(走 dws doc create) | — |
| **无强信号**(如"写日报"、"写周报"、"整理本周工作")| `generate-*-report`(走 dws doc create) | 默认 |
@@ -21,9 +21,9 @@
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-report | **0. 前置判定**:query 含「查日志 / 看日志 / 我发过的日志 / 收到的日志 / 日志详情」且语义指向钉钉日志 OA 应用?是 → 直接走 `dws report`;否 → 先按 doc/report 分歧澄清<br>1. 用户说「我发过 / 我创建」→ `report sent --cursor 0 --size 20 --format json`;用户说「收到 / 别人发给我」→ `report list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json`(注意:`dws report inbox` 当前 CLI 未实现,禁止编造)<br>2. 时间 flag 只允许 `--start` / `--end`,禁止 `--start-date` / `--end-date` / `--date`;不要只传裸日期,必须展开完整 ISO;不要先查 `help`,不要预先登录;只有命令返回认证错误时才处理认证<br>3. 从列表返回中取 `reportId` 留给内部后续调用;如果用户已直接提供 `reportId`,跳过列表<br>4. 面向用户展示列表时必须基于 `result[]` 拼 Markdown 表:`日期 | 标题 | 发送人 | 状态 | 钉钉链接`;每条 `result[]` 都带这五个中文字段;不要把日志 ID 作为主列;已读状态字段缺失则不展示,不要编造<br>5. 用户要看正文时再执行 `report detail --report-id <reportId> --format json`;用户要统计 / 已读情况时执行 `report stats --report-id <reportId> --format json`<br>**不要把 list/sent 当正文接口**;查询正文必须补 `detail` |
| query-report | **0. 前置判定**:query 含「查日志 / 看日志 / 我发过的日志 / 收到的日志 / 日志详情」且语义指向钉钉日志 OA 应用?是 → 直接走 `dws report`;否 → 先按 doc/report 分歧澄清<br>1. 用户说「我发过 / 我创建」→ `report outbox list --cursor 0 --size 20 --format json`;用户说「收到 / 别人发给我」→ `report inbox list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json`<br>2. 时间 flag 只允许 `--start` / `--end`,禁止 `--start-date` / `--end-date` / `--date`;不要只传裸日期,必须展开完整 ISO;不要先查 `help`,不要预先登录;只有命令返回认证错误时才处理认证<br>3. 从列表返回中取 `reportId` 留给内部后续调用;如果用户已直接提供 `reportId`,跳过列表<br>4. 面向用户展示列表时必须基于 `result[]` 拼 Markdown 表:`日期 | 标题 | 发送人 | 状态 | 钉钉链接`;每条 `result[]` 都带这五个中文字段;不要把日志 ID 作为主列;已读状态字段缺失则不展示,不要编造<br>5. 用户要看正文时再执行 `report entry get --report-id <reportId> --format json`;用户要统计 / 已读情况时执行 `report entry stats --report-id <reportId> --format json`<br>**不要把 inbox list/outbox list 当正文接口**;查询正文必须补 `entry get`<br>**不要再生成** `report list` / `report sent` / `report detail` / `report stats`(deprecated alias,仍能跑但会打 stderr 警告) |
| generate-daily-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=今日)<br>2. 交叉汇总并把日报内容写入临时文件 `<tmp>.md`(UTF-8,真实换行)<br>3. **创建文档**:`doc create --name "<日报名>" --content-file <tmp>.md`(> 200KB 按 `dingtalk-doc/references/04-document.md` 的 write-doc 兜底走 create 空 → 循环 update) |
| generate-weekly-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=本周)<br>2. 交叉对比并把周报内容写入临时文件 `<tmp>.md`<br>3. **创建文档**:`doc create --name "<周报名>" --content-file <tmp>.md`(兜底同上) |
| submit-report | **0. 前置判定**:query 含「钉钉日志 / OA 周报模板 / 我的钉钉日志」等强信号?是 → 继续;否 → 切换到 `generate-weekly-report` 或 `generate-daily-report`(走 dws doc)<br>1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=当日)<br>2. `report template list --format json` → 取 `report_template_id`<br>3. `report template detail --name "<模版名>" --format json` → 取 `result.report_template_fields[]`,每项含 `field_name`/`field_sort`/`field_type`<br>4. **把 contents 写入临时文件**(避免 shell 引号问题):每项含 `key`/`sort`/`content`/`contentType`/`type` 五个字段,**严格映射** `field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`<br>5. `report create --template-id <id> --contents '[...]' (或 echo "$json" | ... --contents -) --format json` → CLI 会在创建成功后自动反查详情并追加 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `dingtalkOpenLink` 字段;取返回的 `reportId` 与钉钉打开链接<br>6. final reply 优先直接使用 `dingtalkOpenMarkdownLink`,让用户点击跳转钉钉客户端查看 / 修改;仅当 create 返回中缺少 `dingtalkOpenUrl` 时,才手动执行 `report detail --report-id <reportId> --format json` 补取 `result.url`,再包装成 `[在钉钉中查看日志](result.url)`<br>**不要走 doc 写文档**;**禁止跳过 2/3 步**直接 create;**禁止把 raw `dingtalk://...` URL 直接粘到回复**,必须包成 markdown link |
| generate-monthly-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=当月)<br>2. `report sent --start "<月初ISO>" --end "<月末ISO>"` → 取当月已提交日志<br>3. 按周分段归纳并把月报内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<月报名>" --content-file <tmp>.md`(兜底同上) |
| submit-report | **0. 前置判定**:query 含「钉钉日志 / OA 周报模板 / 我的钉钉日志」等强信号?是 → 继续;否 → 切换到 `generate-weekly-report` 或 `generate-daily-report`(走 dws doc)<br>1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=当日)<br>2. `report template list --format json` → 取 `report_template_id`<br>3. `report template get --name "<模版名>" --format json` → 取 `result.report_template_fields[]`,每项含 `field_name`/`field_sort`/`field_type`<br>4. **把 contents 写入临时文件**(避免 shell 引号问题):每项含 `key`/`sort`/`content`/`contentType`/`type` 五个字段,**严格映射** `field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`<br>5. `report entry submit --template-id <id> --contents-file <tmp>.json --format json` → CLI 会在提交成功后自动反查详情并追加 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `dingtalkOpenLink` 字段;取返回的 `reportId` 与钉钉打开链接<br>6. final reply 优先直接使用 `dingtalkOpenMarkdownLink`,让用户点击跳转钉钉客户端查看 / 修改;仅当 submit 返回中缺少 `dingtalkOpenUrl` 时,才手动执行 `report entry get --report-id <reportId> --format json` 补取 `result.url`,再包装成 `[在钉钉中查看日志](result.url)`<br>**不要走 doc 写文档**;**禁止跳过 2/3 步**直接 submit;**禁止把 raw `dingtalk://...` URL 直接粘到回复**,必须包成 markdown link<br>**不要再生成** `report template detail` / `report create`(deprecated alias,仍能跑但会打 stderr 警告) |
| generate-monthly-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=当月)<br>2. `report outbox list --start "<月初ISO>" --end "<月末ISO>"` → 取当月已提交日志<br>3. 按周分段归纳并把月报内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<月报名>" --content-file <tmp>.md`(兜底同上) |
| generate-topic-report | 1. 提取主题关键词;推断时间范围("最近"默认近 30 天)<br>2. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行<br>3. 按时间线排列,交叉归纳核心结论/决策/行动项/未解决问题/演进脉络,并把内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<报告名>" --content-file <tmp>.md`(兜底同上) |
+39
View File
@@ -0,0 +1,39 @@
---
name: dws-shared
description: dws 多 skill 模式的公共参考——认证、全局参数、多组织 / --profile 规则、安全底线。所有 dingtalk-* 子 skill 执行前先读本 skill。命令前缀:dws。
cli_version: ">=1.0.40"
metadata:
category: productivity
stability: experimental
requires:
bins:
- dws
---
# DWS 公共参考(dws-shared)
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。
每个 dingtalk-* 子 skill 都把本 skill 列为 PREREQUISITE:执行任何产品命令前先读这里的认证、全局参数与多组织规则。`dws` 必须在 PATH 上。
## 认证
- `dws auth login`(新登一个组织即新增 profile);`--device` 无头 / SSH 登录;`--recommend` 无交互批量授权
- `dws auth status [--profile <名称|corpId>]` 查看认证状态
## 全局参数
- 所有命令加 `--format json` 取可解析输出
- 全局 `--profile <名称|corpId>`:单次指定本命令用哪个组织,一次性、不改默认组织
- 危险 / 写 / 删操作执行前先向用户确认
## 多组织 / --profile(关键规则)
dws 可同时登录多个钉钉组织,一个 profile = 一个已登录组织(corp)。当前 profile 决定本次命令用哪个组织的身份(corpId / userId 自动注入)。
- **跨组织铁律**:任何「找群 / 找人 / 找数据」(如 chat / aisearch / contact / doc / wiki / aitable / sheet / minutes / mail / report / todo / calendar / oa 的搜索、列表、查询)在当前组织没命中、且 `dws profile list` 显示 ≥2 个组织时,对每个组织带一次性 `--profile <corpId>` 各搜一遍;命中即用,全部组织都没有才追问用户。禁止在当前组织搜不到就判定「不存在」或直接甩给用户选。
- **单组织**:`dws profile list` 只有 1 个组织时,按当前组织正常处理,不带 `--profile`。
- **安全护栏**:自动跨组织只对「读 / 搜」;写 / 发 / 删 / 撤回等操作默认只在当前组织做,确需带 `--profile` 跨组织写时先与用户确认目标组织;持久切换 `dws profile switch`(改默认组织)属写操作,未经用户明确要求不得执行。
- 完整命令与跨组织聚合见 `dingtalk-profile` skill。
## 错误处理
- `unknown command` / `unknown flag`:先跑 `dws <path> --help` 查证再修正一次,别把自然语言当命令 / flag
- 认证失败 / token 过期:提示用户 `dws auth login` 重新登录
- 业务错误码 / 接口语义:用 `dws devdoc article search --query "<关键词>" --format json` 查官方文档,不编造原因
+5
View File
@@ -7,6 +7,7 @@ import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
"github.com/spf13/cobra"
)
@@ -92,6 +93,10 @@ func getCapture(t *testing.T) *mcpCallCapture {
func setupTestDeps(t *testing.T, _ string) *mcpCallCapture {
t.Helper()
authpkg.SetRuntimeProfile("")
t.Cleanup(func() {
authpkg.SetRuntimeProfile("")
})
cap := &mcpCallCapture{}
linkCapture(t, cap)
return cap