Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba94c0092 | ||
|
|
fe4a79283c | ||
|
|
3ee5f13c62 | ||
|
|
e32fa1535c | ||
|
|
79b8eda3b6 | ||
|
|
be80790172 | ||
|
|
2dbbca1ec9 | ||
|
|
b214c0a06c | ||
|
|
d3087d170b | ||
|
|
49637d982e |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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() + "..."
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 重新登录"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Executable
+124
@@ -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())
|
||||
Executable
+623
@@ -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"
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 图片/文件消息
|
||||
|
||||
@@ -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` 角色查询命令已下线,不要再构造
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
|
||||
@@ -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 类型有通信费用,使用前需和用户确认
|
||||
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -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 获取会话详情
|
||||
|
||||
@@ -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 遇到以下错误时必须自动执行对应恢复动作)
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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>`。退主组织不会被拦截,会静默改选新主,执行前必须向用户确认。
|
||||
@@ -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`(兜底同上) |
|
||||
|
||||
@@ -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` 查官方文档,不编造原因
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user