Compare commits

...
5 Commits
Author SHA1 Message Date
修雨 be80790172 docs(changelog): add [1.0.44] — phantom guard, report contents-file, @file, sheet parity 2026-06-29 10:53:03 +08:00
修雨 2dbbca1ec9 docs(skill): align dws skill references with the real CLI
Every documented command / flag / example now matches `dws <svc> --help`:
- drop phantom commands (attendance class/group/vacation/..., contact label,
  ding message list/receiver-status) that map to undeployed tools.
- fix runtime-failure flags (mail --body→--content, doc/calendar/minutes
  pagination, chat send-by-bot @-flags, wiki member --users).
- route role/duty "who is responsible" queries to `aisearch person --dimension
  duty` instead of the removed `contact label`.
- realign the multi/dingtalk-report skill to entry submit / inbox list / outbox
  list.
2026-06-29 10:53:03 +08:00
修雨 b214c0a06c fix(sheet): wukong parity for range read/update
- accept scalar cells in range update
- flat values projection on read; null clears a cell
- add --hyperlinks flag to range update
2026-06-29 10:53:03 +08:00
修雨 d3087d170b feat(compat): native @file / --contents-file input for structured JSON flags
`dws report entry submit --contents-file <f>` (and `--contents -` stdin) silently
submitted `contents:[null]`. Root cause: the `--contents-file` flag had no
transform, so its value mapped to an unused param while `--contents` stayed empty.

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

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

Adds scripts/dev/check-phantom-overrides.py as a publish-time gate, and
phantom_guard_test.go covering hide / cold-cache-keep / serverOverride / pipeline
/ empty-group-collapse.
2026-06-29 10:53:03 +08:00
34 changed files with 1496 additions and 1586 deletions
+13
View File
@@ -6,9 +6,22 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [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
+45 -1
View File
@@ -351,15 +351,59 @@ func loadDynamicCommands(ctx context.Context, runner executor.Runner) []*cobra.C
detailStart := time.Now()
detailsByID := loadCachedDetailsFast(store, servers)
existingTools := loadCachedToolNames(store, servers)
RecordTiming(ctx, "tool_metadata", time.Since(detailStart))
buildStart := time.Now()
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID)
cmds := compat.BuildDynamicCommands(servers, runner, detailsByID, existingTools)
RecordTiming(ctx, "build_commands", time.Since(buildStart))
return cmds
}
// loadCachedToolNames reads the live tools/list snapshot from disk cache for
// each server and returns a map from CLI server ID (slug) → set of tool names
// the server actually exposes. This is the existence oracle BuildDynamicCommands
// uses to hide phantom override leaves (commands whose backing MCP tool is not
// deployed) from `--help`.
//
// Source note: this reads the `tools/` partition (populated by `dws cache
// refresh` / discovery, keyed by server.Key), NOT the `detail/` partition used
// by loadCachedDetailsFast — the latter is frequently empty even after a
// refresh, so it is unusable as an existence signal.
//
// Keyed by cli.ID so serverOverride routing (e.g. contact → hrmregister)
// resolves against the target server's tool set. A server with no cached tools
// is simply absent from the map; the build guard treats "absent / empty" as
// "unknown" and keeps the command, so a cold cache never blanks the tree.
func loadCachedToolNames(store *cache.Store, servers []market.ServerDescriptor) map[string]map[string]struct{} {
result := make(map[string]map[string]struct{})
if store == nil {
return result
}
partition := editionPartition()
for _, server := range servers {
slug := strings.TrimSpace(server.CLI.ID)
if slug == "" || strings.TrimSpace(server.Key) == "" {
continue
}
snap, _, err := store.LoadTools(partition, server.Key)
if err != nil || len(snap.Tools) == 0 {
continue
}
names := make(map[string]struct{}, len(snap.Tools))
for _, t := range snap.Tools {
if n := strings.TrimSpace(t.Name); n != "" {
names[n] = struct{}{}
}
}
if len(names) > 0 {
result[slug] = names
}
}
return result
}
// loadCachedDetailsFast reads Detail API tool metadata from disk cache only —
// no network calls. Returns whatever is available (fresh or stale).
func loadCachedDetailsFast(store *cache.Store, servers []market.ServerDescriptor) map[string][]market.DetailTool {
+4 -1
View File
@@ -148,8 +148,11 @@ func registerStdioServerFromOverlay(
}
}
// nil existingTools: this overlay is built from the plugin's own live tool
// list (detailsByID is derived from it), so there are no phantom leaves to
// guard against here.
cmds := compat.BuildDynamicCommands(
[]market.ServerDescriptor{descriptor}, runner, detailsByID)
[]market.ServerDescriptor{descriptor}, runner, detailsByID, nil)
slog.Debug("plugin: stdio server registered from overlay",
"plugin", p.Manifest.Name, "server", sc.Key,
+5 -2
View File
@@ -1503,8 +1503,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 +1693,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,
+7 -1
View File
@@ -18,6 +18,7 @@ package cobracmd
import (
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -43,7 +44,7 @@ func FlagChanged(cmd *cobra.Command, name string) bool {
// NewGroupCommand creates a non-leaf parent command that shows help when invoked.
func NewGroupCommand(use, short string) *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: use,
Short: short,
Args: cobra.NoArgs,
@@ -53,6 +54,11 @@ func NewGroupCommand(use, short string) *cobra.Command {
return cmd.Help()
},
}
// Tag as a group container: its RunE only prints help, so cobra's
// Runnable() can't distinguish it from a real leaf — callers that need to
// collapse empty groups rely on this annotation.
cmdutil.MarkGroup(cmd)
return cmd
}
// NewHiddenGroupCommand creates a hidden non-leaf parent command.
+85 -1
View File
@@ -51,8 +51,16 @@ var runtimeDefaultWhitelist = map[string]bool{
// detailsByID maps CLI server ID → []DetailTool from the MCP Detail API.
// When provided, tool Short/Long descriptions and typed flags are enriched from Detail API data.
//
// existingTools maps CLI server ID (slug) → set of tool names that server
// actually exposes (from the live tools/list cache). When a server's set is
// present and non-empty, override leaves whose backing MCP tool is missing from
// it are hidden from `--help` (phantom-command guard). When the set is absent or
// empty (cold cache, or a server we have no tools snapshot for) the guard does
// nothing for that server, so an unpopulated cache never blanks the command
// tree. Pass nil to disable the guard entirely.
//
// Conversion rules reference: docs/mcp-to-cli-conversion.md
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool) []*cobra.Command {
func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool, existingTools map[string]map[string]struct{}) []*cobra.Command {
type builtCmd struct {
cmd *cobra.Command
parent string // cli.Parent: attach as sub-command of this top-level command
@@ -222,6 +230,25 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
cmd := NewDirectCommand(route, runner)
// §guard.toolexists: keep `--help` honest under envelope/deployment
// drift. When we know the resolved server's live tool set and it does
// NOT contain this leaf's backing tool, the command is a phantom
// (renders in help but fails at invocation with "tool not found"), so
// hide it. Safety rails:
// - only acts when the set is KNOWN and non-empty (absent/empty =
// unknown = keep; a cold tools cache must never blank the tree);
// - skips pipeline leaves, which orchestrate multiple tools and have
// no single backing toolName to check;
// - Hidden (not removed) so the command stays invocable for anyone
// who calls it directly — it just leaves the help surface.
if len(override.Pipeline) == 0 && existingTools != nil {
if known, ok := existingTools[canonicalProduct]; ok && len(known) > 0 {
if _, exists := known[toolName]; !exists {
cmd.Hidden = true
}
}
}
// Enrich flags with typed parameters from Detail API toolRequest JSON Schema.
if dt, ok := detailIndex[toolName]; ok && dt.ToolRequest != "" {
buildFlagsFromDetailSchema(cmd, dt.ToolRequest, override.Flags)
@@ -248,6 +275,11 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
// non-attendance.
installAttendanceHook(cmd, canonicalProduct, toolName)
// §report-hook: native --contents-file / --contents - (stdin)
// resolution + one-of(contents, contents-file) relaxation (see
// report_hooks.go). No-op for non-report.
installReportHook(cmd, canonicalProduct, toolName)
// §1.4: Add to the right parent group
attachToGroup(rootCmd, override.Group, groupCmds, cmd)
}
@@ -314,9 +346,61 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run
for _, name := range topOrder {
commands = append(commands, topLevel[name])
}
// §guard.emptygroups: a group whose every leaf is hidden would still show in
// help as an empty heading (e.g. attendance `vacation`/`overtime` once their
// tools are gone). Collapse those. This runs unconditionally because leaves
// get hidden by TWO independent mechanisms — the runtime tool-existence
// guard above AND envelope `hidden:true` overrides — and an envelope-emptied
// group must collapse even when the guard is inert (cold tools cache). It is
// safe regardless of cache state: hideEmptyGroups only ever hides a group all
// of whose children are already hidden; it never hides a leaf, so it cannot
// blank a tree on its own.
for _, c := range commands {
// Collapse empty sub-groups within each product, but never the product
// root itself: a root can legitimately be empty at this point and gain
// visible leaves later from helper/overlay merges, so hiding it here
// could wrongly drop a whole product from `dws --help`.
for _, sub := range c.Commands() {
hideEmptyGroups(sub)
}
}
return commands
}
// hideEmptyGroups recursively hides group commands whose every subcommand is
// hidden — the collateral of the tool-existence guard emptying a group of all
// its leaves. Returns true if cmd is (now) hidden. A command with no
// subcommands is a leaf: its own Hidden flag is returned unchanged. A group is
// only newly hidden when it HAS subcommands and they are ALL hidden, so a group
// retaining at least one visible leaf always stays visible.
func hideEmptyGroups(cmd *cobra.Command) bool {
subs := cmd.Commands()
if len(subs) == 0 {
// No children. A real leaf stands on its own Hidden flag. A group
// container with no children is empty — this happens when every
// override in a group is `hidden:true` (those leaves are never built,
// leaving the group childless) — so hide it. Group containers and leaves
// both have a RunE, so cobra's Runnable() can't tell them apart; the
// group annotation can.
if cmdutil.IsGroup(cmd) {
cmd.Hidden = true
return true
}
return cmd.Hidden
}
allHidden := true
for _, sub := range subs {
if !hideEmptyGroups(sub) {
allHidden = false
}
}
if allHidden {
cmd.Hidden = true
}
return cmd.Hidden
}
// buildDetailIndex creates a map from toolName → DetailTool for fast lookup.
func buildDetailIndex(tools []market.DetailTool) map[string]market.DetailTool {
idx := make(map[string]market.DetailTool, len(tools))
+38 -38
View File
@@ -77,7 +77,7 @@ func TestBuildDynamicCommands_ParentNesting(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
// Should produce only one top-level command: "chat"
if len(cmds) != 1 {
@@ -130,7 +130,7 @@ func TestBuildDynamicCommands_ParentNotFound(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
// Parent not found, should fall back to top-level
if len(cmds) != 1 {
@@ -169,7 +169,7 @@ func TestBuildDynamicCommands_ShorthandFlag(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("expected 1 cmd, got %d", len(cmds))
}
@@ -212,7 +212,7 @@ func TestBuildDynamicCommands_RequiredFlag(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -253,7 +253,7 @@ func TestBuildDynamicCommands_RequiredIgnoredWhenPositional(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -292,7 +292,7 @@ func TestBuildDynamicCommands_PositionalArg(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -347,7 +347,7 @@ func TestBuildDynamicCommands_PositionalArgInjection(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -399,7 +399,7 @@ func TestBuildDynamicCommands_PositionalWithFlagAliases(t *testing.T) {
},
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
article := findChild(cmds[0], "article")
if article == nil {
t.Fatal("article group not found")
@@ -552,7 +552,7 @@ func TestBuildDynamicCommands_PositionalArityMixed(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "do")
if leaf == nil {
t.Fatal("do leaf not found")
@@ -606,7 +606,7 @@ func TestBuildDynamicCommands_ServerOverride(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
leaf := findChild(cmds[0], "bot-list")
if leaf == nil {
t.Fatal("bot-list leaf not found")
@@ -641,7 +641,7 @@ func TestBuildDynamicCommands_ServerOverrideFallback(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, captured, nil)
cmds := BuildDynamicCommands(servers, captured, nil, nil)
leaf := findChild(cmds[0], "list")
if leaf == nil {
t.Fatal("list leaf not found")
@@ -680,7 +680,7 @@ func TestBuildDynamicCommands_DescriptionOverridesUsage(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -732,7 +732,7 @@ func TestBuildDynamicCommands_OverlayFlagWinsOverDetailSchema(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
send := findChild(cmds[0], "send")
if send == nil {
t.Fatal("send leaf not found")
@@ -775,7 +775,7 @@ func TestBuildDynamicCommands_BodyWrapper(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
create := findChild(cmds[0], "create")
if create == nil {
t.Fatal("create leaf not found")
@@ -858,7 +858,7 @@ func TestBuildDynamicCommands_MutuallyExclusive(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
cmds[0].SetArgs([]string{"list", "--group", "g1", "--user", "u1"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -901,7 +901,7 @@ func TestBuildDynamicCommands_RequireOneOf(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
cmds[0].SetArgs([]string{"list"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -940,7 +940,7 @@ func TestBuildDynamicCommands_RequireOneOfSatisfied(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"list", "--group", "g1"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -974,7 +974,7 @@ func TestBuildDynamicCommands_RedirectTo(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
history := findChild(cmds[0], "history")
if history == nil {
t.Fatal("history stub not found")
@@ -1029,7 +1029,7 @@ func TestBuildDynamicCommands_Hints(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
root := cmds[0]
// history hint attached directly under root.
@@ -1094,7 +1094,7 @@ func TestBuildDynamicCommands_UnknownFlagConstraintSkipped(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found (constraint validation must not abort build)")
@@ -1135,7 +1135,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimarySet(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--query", "hello"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1193,7 +1193,7 @@ func TestBuildDynamicCommands_MultipleAliases_OnlyAliasSet(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--keyword", "hi"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1234,7 +1234,7 @@ func TestBuildDynamicCommands_MultipleAliases_RequiredErrorWhenNoneSet(t *testin
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1275,7 +1275,7 @@ func TestBuildDynamicCommands_MultipleAliases_PrimaryWinsWhenBothSet(t *testing.
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--query", "primary", "--keyword", "fallback"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1316,7 +1316,7 @@ func TestBuildDynamicCommands_MultipleAliases_MultiAliasChain(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"get", "--user-ids", "u1,u2"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1383,7 +1383,7 @@ func TestBuildDynamicCommands_MultipleAliases_Dedup(t *testing.T) {
// If ApplyBindings panics (duplicate pflag) we fail. Otherwise the cmd
// should build and execute fine.
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"search", "--keyword", "ok"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -1421,7 +1421,7 @@ func TestBuildDynamicCommands_NoParent(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 2 {
t.Fatalf("expected 2 top-level commands, got %d", len(cmds))
@@ -1457,7 +1457,7 @@ func TestBuildDynamicCommands_ExampleField(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
approval := findChild(cmds[0], "approval")
if approval == nil {
t.Fatal("approval group not found")
@@ -1503,7 +1503,7 @@ func TestApplyBindings_VisibleFlagDefault_String(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "list-forms")
if leaf == nil {
t.Fatal("list-forms leaf not found")
@@ -1548,7 +1548,7 @@ func TestApplyBindings_VisibleFlagDefault_Int(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
leaf := findChild(cmds[0], "list-forms")
if leaf == nil {
t.Fatal("list-forms leaf not found")
@@ -1786,7 +1786,7 @@ func TestBuildDynamicCommands_ParentMergeSameName(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 || cmds[0].Name() != "chat" {
t.Fatalf("expected single top-level 'chat', got %d cmds", len(cmds))
}
@@ -1860,7 +1860,7 @@ func TestBuildDynamicCommands_ParentMergeRecursive(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 || cmds[0].Name() != "chat" {
t.Fatalf("expected single top-level 'chat', got %d", len(cmds))
}
@@ -1934,7 +1934,7 @@ func TestBuildDynamicCommands_ParentMergeLeafCollision(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("expected 1 top-level, got %d", len(cmds))
}
@@ -1987,7 +1987,7 @@ func TestBuildFlagsFromDetailSchema_FormatEnumAnnotations(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details)
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, details, nil)
list := findChild(cmds[0], "list")
if list == nil {
t.Fatal("list leaf not found")
@@ -2052,7 +2052,7 @@ func TestBuildDynamicCommands_MapsTo_WithoutTransform(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "# 标题"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2107,7 +2107,7 @@ func TestBuildDynamicCommands_MapsTo_WithFileReadTransform(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content-file", path})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2157,7 +2157,7 @@ func TestBuildDynamicCommands_MapsTo_SiblingFlagsExclusiveSetOne(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "literal body"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
@@ -2207,7 +2207,7 @@ func TestBuildDynamicCommands_MapsTo_BothSetIsRejectedByCobra(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, runner, nil)
cmds := BuildDynamicCommands(servers, runner, nil, nil)
cmds[0].SetArgs([]string{"update", "--node", "n1", "--content", "x", "--content-file", "/tmp/y"})
cmds[0].SilenceErrors = true
cmds[0].SilenceUsage = true
+257
View File
@@ -0,0 +1,257 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
"github.com/spf13/cobra"
)
// findLeaf returns the first leaf command with the given Use anywhere under
// root (depth-first), or nil.
func findLeaf(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
if got := findLeaf(c, name); got != nil {
return got
}
}
return nil
}
func toolSet(names ...string) map[string]struct{} {
s := make(map[string]struct{}, len(names))
for _, n := range names {
s[n] = struct{}{}
}
return s
}
// attendanceLike builds one server with a real tool and a phantom tool, the
// exact shape of the production drift (e.g. attendance: only a handful of the
// declared overrides map to deployed tools).
func attendanceLike() []market.ServerDescriptor {
return []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
ToolOverrides: map[string]market.CLIToolOverride{
"get_attendance_summary": {CLIName: "summary"}, // real
"get_overtime_rule": {CLIName: "overtime"}, // phantom
},
},
},
}
}
// TestPhantomGuard_HidesWhenToolSetKnown is the core behaviour: when the live
// tool set is known and non-empty, a leaf whose backing tool is absent is
// hidden from --help while the real leaf stays visible.
func TestPhantomGuard_HidesWhenToolSetKnown(t *testing.T) {
t.Parallel()
existing := map[string]map[string]struct{}{
"attendance": toolSet("get_attendance_summary"), // overtime is NOT deployed
}
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, existing)
summary := findLeaf(cmds[0], "summary")
overtime := findLeaf(cmds[0], "overtime")
if summary == nil || overtime == nil {
t.Fatalf("both leaves should still be registered (invocable); summary=%v overtime=%v", summary, overtime)
}
if summary.Hidden {
t.Error("real command 'summary' must stay visible in --help")
}
if !overtime.Hidden {
t.Error("phantom command 'overtime' must be hidden from --help")
}
}
// TestPhantomGuard_ColdCacheKeepsEverything is the safety rail that the prior
// (source-blind) plan got wrong: with no tool set available (nil map), the
// guard must do nothing — never blank the command tree on a cold cache.
func TestPhantomGuard_ColdCacheKeepsEverything(t *testing.T) {
t.Parallel()
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, nil)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("cold cache (nil existingTools) must not hide %q", name)
}
}
}
// TestPhantomGuard_EmptyOrAbsentSetKeepsEverything: an empty set for a server,
// or a server missing from the map entirely, both mean "unknown" — keep all.
func TestPhantomGuard_EmptyOrAbsentSetKeepsEverything(t *testing.T) {
t.Parallel()
cases := []struct {
name string
existing map[string]map[string]struct{}
}{
{"empty set for server", map[string]map[string]struct{}{"attendance": {}}},
{"server absent from map", map[string]map[string]struct{}{"someother": toolSet("x")}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmds := BuildDynamicCommands(attendanceLike(), executor.EchoRunner{}, nil, tc.existing)
for _, name := range []string{"summary", "overtime"} {
leaf := findLeaf(cmds[0], name)
if leaf == nil {
t.Fatalf("%q should be registered", name)
}
if leaf.Hidden {
t.Errorf("%s: must not hide %q when tool set is unknown", tc.name, name)
}
}
})
}
}
// TestPhantomGuard_ServerOverrideRoutesToTargetSet: a leaf with serverOverride
// must be checked against the TARGET server's tool set, not the host's. This is
// what prevents false-flagging legit cross-server routes (contact→hrmregister,
// doc→doc-comment).
func TestPhantomGuard_ServerOverrideRoutesToTargetSet(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-contact",
CLI: market.CLIOverlay{
ID: "contact",
Command: "contact",
ToolOverrides: map[string]market.CLIToolOverride{
// routed to hrmregister; the tool lives there, not in contact
"get_roster": {CLIName: "roster", ServerOverride: "hrmregister"},
},
},
},
}
// contact's own set is empty of get_roster, but hrmregister has it.
existing := map[string]map[string]struct{}{
"contact": toolSet("search_user"),
"hrmregister": toolSet("get_roster"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
roster := findLeaf(cmds[0], "roster")
if roster == nil {
t.Fatal("roster leaf should be registered")
}
if roster.Hidden {
t.Error("serverOverride leaf must resolve against the target server's set and stay visible")
}
}
// TestPhantomGuard_EmptyGroupCollapses: a group all of whose overrides are
// hidden:true (so none of its leaves are built) must itself be hidden from
// help, while a group keeping at least one visible leaf stays. This runs
// regardless of the tools-cache oracle (envelope hidden:true is cache-
// independent), so existingTools is nil here.
func TestPhantomGuard_EmptyGroupCollapses(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-attendance",
CLI: market.CLIOverlay{
ID: "attendance",
Command: "attendance",
Groups: map[string]market.CLIGroupDef{
"vacation": {Description: "假期管理"}, // all leaves hidden -> collapse
"record": {Description: "考勤记录"}, // keeps a visible leaf
},
ToolOverrides: map[string]market.CLIToolOverride{
"get_leave_types": {CLIName: "types", Group: "vacation", Hidden: true},
"get_leave_balance_quota": {CLIName: "balance", Group: "vacation", Hidden: true},
"get_user_attendance_record": {CLIName: "get", Group: "record"},
},
},
},
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, nil)
vacation := findGroup(cmds[0], "vacation")
record := findGroup(cmds[0], "record")
if vacation == nil || record == nil {
t.Fatalf("both groups should exist as commands; vacation=%v record=%v", vacation, record)
}
if !vacation.Hidden {
t.Error("group 'vacation' with only hidden leaves must collapse (be hidden)")
}
if record.Hidden {
t.Error("group 'record' with a visible leaf must stay visible")
}
}
// findGroup returns a direct child of root with the given name (groups attach
// directly under the product root).
func findGroup(root *cobra.Command, name string) *cobra.Command {
for _, c := range root.Commands() {
if c.Name() == name {
return c
}
}
return nil
}
// TestPhantomGuard_PipelineLeafNeverHidden: pipeline leaves orchestrate multiple
// tools and have no single backing toolName, so the guard must skip them even
// when the override key is not a deployed tool.
func TestPhantomGuard_PipelineLeafNeverHidden(t *testing.T) {
t.Parallel()
servers := []market.ServerDescriptor{
{
Endpoint: "https://endpoint-im",
CLI: market.CLIOverlay{
ID: "im",
Command: "im",
ToolOverrides: map[string]market.CLIToolOverride{
"download_media": {
CLIName: "download-media",
Pipeline: []market.PipelineStep{
{Tool: "get_resource_download_url"},
},
},
},
},
},
}
// download_media itself is not a deployed tool name, but the pipeline is.
existing := map[string]map[string]struct{}{
"im": toolSet("get_resource_download_url"),
}
cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil, existing)
dl := findLeaf(cmds[0], "download-media")
if dl == nil {
t.Fatal("download-media leaf should be registered")
}
if dl.Hidden {
t.Error("pipeline leaf must never be hidden by the tool-existence guard")
}
}
+2 -2
View File
@@ -83,7 +83,7 @@ func TestBuildDynamicCommandsSurvivesMalformedFlagEnvelope(t *testing.T) {
}
// Must not panic; the command must build and stay executable.
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
if len(cmds) != 1 {
t.Fatalf("BuildDynamicCommands() = %d commands, want 1", len(cmds))
}
@@ -120,7 +120,7 @@ func TestBuildDynamicCommandsKeepsFirstShorthand(t *testing.T) {
},
}
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil)
cmds := BuildDynamicCommands(servers, &captureRunner{}, nil, nil)
boom, _, err := cmds[0].Find([]string{"boom"})
if err != nil {
t.Fatalf("find boom: %v", err)
+186
View File
@@ -0,0 +1,186 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// report_hooks.go — CLI-side input resolution for the `report` product.
//
// The envelope publishes `report entry submit` (MCP tool create_report) with a
// `--contents` flag (json_parse, required) and a sibling `--contents-file`
// flag (omitWhen empty, no transform/mapsTo). On its own, `--contents-file`
// therefore goes nowhere: its value maps to the unused `contentsFile` param and
// the real `contents` param stays empty, so a `--contents-file`-only (or
// `--contents -` stdin) submit silently sends `contents: [null]` and the report
// fails. The literal-only `--contents` path works, which is why
// `report create` (the helper, inline-only) succeeds while
// `report entry submit --contents-file` does not.
//
// The wukong reference implementation reads the file/stdin natively inside its
// hand-written cobra RunE (dws-wukong/wukong/products/report.go
// resolveReportContentsFromFlags, priority: --contents-file > --contents -
// (stdin) > --contents '<json>'). The open-source CLI is envelope-driven, so we
// attach the equivalent native resolution as a build-time hook here, mirroring
// AttachReportListReadableEnrichment (which layers wukong-equivalent list
// enrichment onto the same envelope leaves). No discovery-config change is
// needed: the hook populates the real `--contents` flag before the envelope's
// json_parse transform runs, and the broken `contentsFile` override is left
// inert.
//
// Two build-time adjustments make `--contents-file`-only valid:
//
// 1. The envelope marks `--contents` individually required (cobra
// MarkFlagRequired, enforced at parse time, before PreRunE). We clear that
// annotation and instead declare a `contents` / `contents-file` one-of
// group (MarkFlagsOneRequired, validated by ValidateFlagGroups — also
// before PreRunE, but satisfied when either flag is set). Supplying
// neither still errors, now naming both flags.
// 2. A chained PreRunE resolves the chosen source into `--contents` so the
// downstream json_parse transform sees inline JSON regardless of origin.
package compat
import (
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/spf13/cobra"
)
// reportContentsMaxBytes caps the contents payload at 10MB, matching the
// wukong upstream limit (dws-wukong/wukong/products/report.go
// reportContentsMaxBytes). Oversized input is rejected rather than truncated.
const reportContentsMaxBytes = 10 * 1024 * 1024
// reportToolsWithContentsFile lists every report toolName whose `--contents` /
// `--contents-file` pair needs native file/stdin resolution. Today only
// create_report (the `report entry submit` leaf) carries the pair.
var reportToolsWithContentsFile = map[string]bool{
"create_report": true,
}
// installReportHook wires report-specific input resolution onto leaf commands
// emitted by BuildDynamicCommands. It is a no-op for non-report products and
// for report tools that do not expose the contents/contents-file pair.
//
// The hook chain preserves the cmd.PreRunE that NewDirectCommand already
// installed (currently validateRequireTogether) by invoking it first.
func installReportHook(cmd *cobra.Command, canonicalProduct, toolName string) {
if cmd == nil {
return
}
if strings.TrimSpace(canonicalProduct) != "report" {
return
}
if !reportToolsWithContentsFile[toolName] {
return
}
contents := cmd.Flags().Lookup("contents")
file := cmd.Flags().Lookup("contents-file")
if contents == nil || file == nil {
// Envelope shape changed (renamed/removed flags) — do not block the
// command; leave whatever the envelope declared untouched.
return
}
// (1) Relax the individually-required `--contents` into a one-of group so
// `--contents-file`-only (or `--contents -`) is accepted. Clearing the
// required annotation must happen before parse-time ValidateRequiredFlags;
// this hook runs at build time, so it does.
if contents.Annotations != nil {
delete(contents.Annotations, cobra.BashCompOneRequiredFlag)
}
cmd.MarkFlagsOneRequired("contents", "contents-file")
// (2) Resolve the chosen source into --contents before the RunE transform.
original := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if original != nil {
if err := original(c, args); err != nil {
return err
}
}
return resolveReportContents(c)
}
}
// resolveReportContents applies the wukong source priority — `--contents-file`
// (file) > `--contents -` (stdin) > `--contents '<json>'` (literal) — and
// writes the resolved JSON string back into the `--contents` flag so the
// downstream json_parse transform decodes it uniformly. When a file or stdin
// source is used, `--contents-file` is cleared so the envelope's omitWhen:empty
// drops the now-redundant param.
func resolveReportContents(cmd *cobra.Command) error {
filePath, _ := cmd.Flags().GetString("contents-file")
if strings.TrimSpace(filePath) != "" {
data, err := readReportContentsFile(filePath)
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from --contents-file")
}
_ = cmd.Flags().Set("contents-file", "")
return nil
}
raw, _ := cmd.Flags().GetString("contents")
if strings.TrimSpace(raw) == "-" {
data, err := readReportContentsLimited(cmd.InOrStdin(), "--contents -")
if err != nil {
return err
}
if err := cmd.Flags().Set("contents", data); err != nil {
return apperrors.NewInternal("failed to set --contents from stdin")
}
}
return nil
}
// readReportContentsFile opens a file path and reads its contents under the
// 10MB cap and UTF-8 check. Error wording mirrors wukong so agents and humans
// see a stable message across both editions.
func readReportContentsFile(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return "", apperrors.NewValidation(
fmt.Sprintf("--contents-file: file not found: %s", path),
apperrors.WithHint("确认路径存在,且指向一个 JSON 文件"),
)
}
return "", apperrors.NewValidation(fmt.Sprintf("--contents-file: cannot read %s: %v", path, err))
}
defer file.Close()
return readReportContentsLimited(file, fmt.Sprintf("--contents-file %s", path))
}
// readReportContentsLimited reads from r enforcing the 10MB cap and UTF-8
// validity. A LimitReader at cap+1 detects overflow without reading unbounded.
func readReportContentsLimited(r io.Reader, source string) (string, error) {
data, err := io.ReadAll(io.LimitReader(r, int64(reportContentsMaxBytes)+1))
if err != nil {
return "", apperrors.NewValidation(fmt.Sprintf("%s: read failed: %v", source, err))
}
if len(data) > reportContentsMaxBytes {
return "", apperrors.NewValidation(
fmt.Sprintf("%s: contents exceed maximum size of 10MB", source),
apperrors.WithHint("精简内容或拆分为多份日志提交"),
)
}
if !utf8.Valid(data) {
return "", apperrors.NewValidation(fmt.Sprintf("%s: not valid UTF-8", source))
}
return string(data), nil
}
+198
View File
@@ -0,0 +1,198 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package compat
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
// newReportSubmitStub mirrors the leaf command shape emitted by
// BuildDynamicCommands for `report entry submit` (envelope: create_report).
// Only the flags the hook touches are registered. --contents is marked
// required to reproduce the envelope's MarkFlagRequired so the relaxation
// behaviour can be asserted.
func newReportSubmitStub() *cobra.Command {
cmd := &cobra.Command{Use: "submit", RunE: func(*cobra.Command, []string) error { return nil }}
cmd.Flags().String("contents", "", "contents JSON array")
cmd.Flags().String("contents-file", "", "contents JSON file")
cmd.Flags().String("template-id", "", "template id")
_ = cmd.MarkFlagRequired("contents")
return cmd
}
const reportContentsPayload = `[{"key":"今日完成工作","sort":"0","content":"done","contentType":"markdown","type":"1"}]`
func TestResolveReportContents_FromFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(file): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from file: %q", got)
}
// contents-file must be cleared so omitWhen:empty drops the dead param.
if cf, _ := cmd.Flags().GetString("contents-file"); cf != "" {
t.Fatalf("--contents-file should be cleared after resolution, got %q", cf)
}
}
func TestResolveReportContents_FromStdin(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", "-"); err != nil {
t.Fatal(err)
}
cmd.SetIn(strings.NewReader(reportContentsPayload))
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(stdin): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents not populated from stdin: %q", got)
}
}
func TestResolveReportContents_InlineUntouched(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents(inline): %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("inline --contents must be left untouched, got %q", got)
}
}
func TestResolveReportContents_FilePriorityOverInline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "contents.json")
if err := os.WriteFile(path, []byte(reportContentsPayload), 0o600); err != nil {
t.Fatal(err)
}
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents", `[{"stale":"inline"}]`); err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("contents-file", path); err != nil {
t.Fatal(err)
}
if err := resolveReportContents(cmd); err != nil {
t.Fatalf("resolveReportContents: %v", err)
}
got, _ := cmd.Flags().GetString("contents")
if got != reportContentsPayload {
t.Fatalf("--contents-file must win over inline --contents, got %q", got)
}
}
func TestResolveReportContents_MissingFileErrors(t *testing.T) {
cmd := newReportSubmitStub()
if err := cmd.Flags().Set("contents-file", filepath.Join(t.TempDir(), "nope.json")); err != nil {
t.Fatal(err)
}
err := resolveReportContents(cmd)
if err == nil {
t.Fatal("expected error for missing --contents-file path")
}
if !strings.Contains(err.Error(), "file not found") {
t.Fatalf("unexpected error: %v", err)
}
}
// ── installReportHook composition ──────────────────────────────
func TestInstallReportHook_RelaxesRequiredToOneOf(t *testing.T) {
cmd := newReportSubmitStub()
// Before the hook, --contents carries the cobra required annotation.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("precondition: --contents should start out required")
}
installReportHook(cmd, "report", "create_report")
// After the hook, the individual required annotation must be cleared so a
// --contents-file-only invocation is not rejected at parse time.
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] != nil {
t.Fatal("installReportHook should clear the individual required on --contents")
}
// And a PreRunE must now be installed to resolve the source.
if cmd.PreRunE == nil {
t.Fatal("installReportHook should install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherProduct(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "chat", "create_report")
if cmd.Flags().Lookup("contents").Annotations[cobra.BashCompOneRequiredFlag] == nil {
t.Fatal("non-report product must not touch required annotation")
}
if cmd.PreRunE != nil {
t.Fatal("non-report product must not install a PreRunE")
}
}
func TestInstallReportHook_NoOpForOtherReportTool(t *testing.T) {
cmd := newReportSubmitStub()
installReportHook(cmd, "report", "get_received_report_list")
if cmd.PreRunE != nil {
t.Fatal("non-target report tool must not install a PreRunE")
}
}
func TestInstallReportHook_ChainsExistingPreRunE(t *testing.T) {
cmd := newReportSubmitStub()
originalCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error { originalCalled = true; return nil }
installReportHook(cmd, "report", "create_report")
if err := cmd.Flags().Set("contents", reportContentsPayload); err != nil {
t.Fatal(err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("unexpected err: %v", err)
}
if !originalCalled {
t.Fatal("original PreRunE was dropped")
}
}
func TestInstallReportHook_BailsIfChainedPreRunEFails(t *testing.T) {
cmd := newReportSubmitStub()
cmd.PreRunE = func(*cobra.Command, []string) error { return errors.New("original boom") }
installReportHook(cmd, "report", "create_report")
err := cmd.PreRunE(cmd, nil)
if err == nil || !strings.Contains(err.Error(), "original boom") {
t.Fatalf("expected original PreRunE error to bubble, got %v", err)
}
}
func TestInstallReportHook_NilCmdSafe(t *testing.T) {
installReportHook(nil, "report", "create_report")
}
+45 -2
View File
@@ -243,6 +243,18 @@ func transformJSONParse(value any) (any, error) {
if s == "" {
return value, nil
}
// @file / @- expansion — read the JSON/YAML payload from a file or stdin
// before parsing. A leading "@" is an unambiguous file sentinel because a
// JSON/YAML value never starts with "@"; this is what the error hint below
// promises and lets long/complex payloads (many records, big cell ranges)
// avoid shell-quoting hell.
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
// Strict JSON first — fast path and unambiguous type promotion (numbers
// stay numbers, etc.).
var parsed any
@@ -257,10 +269,32 @@ func transformJSONParse(value any) (any, error) {
return nil, apperrors.NewValidation(
"json_parse: input is not valid JSON or YAML; " +
"quote the whole value and use `[{key: value, ...}]` for ad-hoc input, " +
"or pass `@path/to/file.json` to read from a file",
"or pass `@path/to/file.json` (or `@-` for stdin) to read from a file",
)
}
// resolveJSONSource expands an @file / @- reference used by the json_parse
// transforms. A leading "@" is the file sentinel: "@-" reads stdin, "@<path>"
// reads the file (UTF-8, via transformFileRead). Any value not starting with
// "@" is returned unchanged. JSON/YAML payloads never start with "@", so this
// is unambiguous for structured flags.
func resolveJSONSource(s string) (string, error) {
if !strings.HasPrefix(s, "@") {
return s, nil
}
ref := strings.TrimSpace(s[1:])
if ref == "" {
return "", apperrors.NewValidation(
"json_parse: `@` must be followed by a file path, or `@-` to read from stdin")
}
out, err := transformFileRead(ref)
if err != nil {
return "", err
}
loaded, _ := out.(string)
return strings.TrimSpace(loaded), nil
}
// transformJSONParseStrict is the strict variant of json_parse: only accepts
// well-formed JSON, rejecting input that the YAML fallback would otherwise
// silently coerce to a scalar string. Use when the upstream tool requires a
@@ -274,12 +308,21 @@ func transformJSONParseStrict(value any) (any, error) {
if s == "" {
return value, nil
}
// @file / @- expansion — same sentinel as json_parse (see resolveJSONSource).
s, err := resolveJSONSource(s)
if err != nil {
return nil, err
}
if s == "" {
return value, nil
}
var parsed any
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
return nil, apperrors.NewValidation(
"json_parse_strict: input is not valid JSON; " +
"this transform rejects YAML-style ad-hoc input — quote the whole value " +
"as strict JSON (e.g. '[{\"key\":\"value\"}]') or use `json_parse` for YAML-tolerant parsing",
"as strict JSON (e.g. '[{\"key\":\"value\"}]'), pass `@path/to/file.json` " +
"(or `@-` for stdin), or use `json_parse` for YAML-tolerant parsing",
)
}
return parsed, nil
+59
View File
@@ -127,6 +127,65 @@ func TestJSONParse_InvalidInput(t *testing.T) {
}
}
func TestJSONParse_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "payload.json")
if err := os.WriteFile(path, []byte(`[{"k":"长内容\n多行","n":1}]`), 0o600); err != nil {
t.Fatalf("write temp file: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse", nil)
if err != nil {
t.Fatalf("json_parse @file: %v", err)
}
arr, ok := got.([]any)
if !ok || len(arr) != 1 {
t.Fatalf("expected 1-element array from @file, got %T %v", got, got)
}
item := arr[0].(map[string]any)
if item["k"] != "长内容\n多行" {
t.Fatalf("@file content mismatch: %v", item)
}
}
func TestJSONParse_AtFileMissing(t *testing.T) {
if _, err := ApplyTransform("@"+filepath.Join(t.TempDir(), "nope.json"), "json_parse", nil); err == nil {
t.Fatal("json_parse @missing-file should error")
}
}
func TestJSONParse_BareAtErrors(t *testing.T) {
_, err := ApplyTransform("@", "json_parse", nil)
if err == nil {
t.Fatal("bare @ should error (needs a path or -)")
}
}
func TestJSONParseStrict_AtFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "p.json")
if err := os.WriteFile(path, []byte(`{"a":[1,2,3]}`), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
got, err := ApplyTransform("@"+path, "json_parse_strict", nil)
if err != nil {
t.Fatalf("json_parse_strict @file: %v", err)
}
if _, ok := got.(map[string]any); !ok {
t.Fatalf("expected object, got %T", got)
}
}
func TestJSONParse_AtPassthroughNonAt(t *testing.T) {
// A value not starting with "@" must be parsed inline, untouched.
got, err := ApplyTransform(`[{"x":1}]`, "json_parse", nil)
if err != nil {
t.Fatalf("inline json_parse: %v", err)
}
if arr, ok := got.([]any); !ok || len(arr) != 1 {
t.Fatalf("inline parse regressed: %T %v", got, got)
}
}
// TestFileRead_BasicFile exercises the happy path: a UTF-8 file on disk is
// read in full and surfaced as a string value. This is the contract the
// `--content-file ./a.md` flag relies on so the upstream MCP tool sees the
+125 -13
View File
@@ -25,6 +25,7 @@ import (
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
@@ -316,6 +317,10 @@ func newSheetRangeReadCommand(runner executor.Runner) *cobra.Command {
// 使"清除后回读不再含该 key"成立(与 wukong 对齐)。dry-run 不投影。
if !commandDryRun(cmd) {
sheetCleanCellInfos(result.Response)
// Project a flat `values` 2D array alongside the rich `cells` so
// consumers expecting wukong's get_range shape (values: [["a"]])
// work while `cells` (styles/richText) stays available.
sheetProjectFlatValues(result.Response)
}
return writeCommandPayload(cmd, result)
})
@@ -329,22 +334,55 @@ func newSheetRangeReadCommand(runner executor.Runner) *cobra.Command {
func newSheetRangeUpdateCommand(runner executor.Runner) *cobra.Command {
cmd := newSheetLeaf("update", "更新工作表指定区域内容", func(cmd *cobra.Command, _ []string) error {
if err := sheetValidateRequired(cmd, "node", "sheet-id", "range", "values"); err != nil {
if err := sheetValidateRequired(cmd, "node", "sheet-id", "range"); err != nil {
return err
}
hasValues := sheetStringFlag(cmd, "values") != ""
hasHyperlinks := sheetStringFlag(cmd, "hyperlinks") != ""
if !hasValues && !hasHyperlinks {
return apperrors.NewValidation("--values 或 --hyperlinks 至少传入一项")
}
var cells [][]any
if err := sheetParseJSONFlag(cmd, "values", &cells); err != nil {
return err
if hasValues {
if err := sheetParseJSONFlag(cmd, "values", &cells); err != nil {
return err
}
}
// --hyperlinks overlays cell-level links onto the same grid (matches
// wukong update_range: hyperlinks coexist with values, hyperlinks win).
// Each item is wukong's {type:path,link,text}, which is exactly open's
// cell `hyperlink` field shape.
if hasHyperlinks {
var hyperlinks [][]any
if err := sheetParseJSONFlag(cmd, "hyperlinks", &hyperlinks); err != nil {
return err
}
merged, err := sheetMergeHyperlinks(cells, hyperlinks)
if err != nil {
return err
}
cells = merged
}
for i, row := range cells {
for j, cell := range row {
path := fmt.Sprintf("--values[%d][%d]", i, j)
if cell == nil {
return apperrors.NewValidation(fmt.Sprintf("%s: 不支持 null,每个单元格必须是 {type:text,...}、{type:richText,...}、{hyperlink:...} 对象,或 {} 表示保留原值", path))
// null clears the cell content (matches wukong's update_range
// pass-through). Use {} (keep-original) for "leave unchanged".
cells[i][j] = map[string]any{"type": "text", "text": ""}
continue
}
// Auto-wrap a scalar cell (string / number / bool) into a text
// cell so the simple form --values '[["姓名","部门"]]' works, the
// same shape `append` and wukong's update_range accept. Explicit
// object cells fall through to the rich validation below.
if wrapped, ok := sheetWrapScalarCell(cell); ok {
cells[i][j] = wrapped
continue
}
cellMap, ok := cell.(map[string]any)
if !ok {
return apperrors.NewValidation(fmt.Sprintf("%s: 必须为 object(如 {\"type\":\"text\",\"text\":\"...\"})", path))
return apperrors.NewValidation(fmt.Sprintf("%s: 必须为 object(如 {\"type\":\"text\",\"text\":\"...\"})、纯字符串/数字,或 {} 表示保留原值", path))
}
// {} 空对象 = 跳过该单元格,保留原值不变
if len(cellMap) == 0 {
@@ -364,10 +402,59 @@ func newSheetRangeUpdateCommand(runner executor.Runner) *cobra.Command {
})
addSheetBaseFlags(cmd)
cmd.Flags().String("range", "", "目标单元格区域地址 (必填)")
cmd.Flags().String("values", "", "单元格内容二维 JSON 数组 (必填)")
cmd.Flags().String("values", "", "单元格内容二维 JSON 数组 (--values 或 --hyperlinks 至少一项)")
cmd.Flags().String("hyperlinks", "", "超链接二维 JSON 数组,每项 {type:path,link,text};与 --values 共存时按位叠加")
return cmd
}
// sheetMergeHyperlinks overlays a hyperlinks grid onto a values cell grid so
// `sheet range update` accepts the separate --hyperlinks flag (wukong shape).
// Each non-null hyperlink item is wukong's {type:path,link,text}, attached as
// the cell's `hyperlink` field; a scalar value cell is first wrapped to a text
// cell so the link has a host cell. Returns a grid sized to the larger of the
// two inputs.
func sheetMergeHyperlinks(cells [][]any, hyperlinks [][]any) ([][]any, error) {
rows := len(cells)
if len(hyperlinks) > rows {
rows = len(hyperlinks)
}
out := make([][]any, rows)
for i := 0; i < rows; i++ {
var valRow, linkRow []any
if i < len(cells) {
valRow = cells[i]
}
if i < len(hyperlinks) {
linkRow = hyperlinks[i]
}
cols := len(valRow)
if len(linkRow) > cols {
cols = len(linkRow)
}
row := make([]any, cols)
for j := 0; j < cols; j++ {
if j < len(valRow) {
row[j] = valRow[j]
}
if j < len(linkRow) && linkRow[j] != nil {
cell, ok := row[j].(map[string]any)
if !ok {
// No (or scalar) value cell here — host the link on a cell.
if wrapped, wok := sheetWrapScalarCell(row[j]); wok {
cell = wrapped
} else {
cell = map[string]any{}
}
}
cell["hyperlink"] = linkRow[j]
row[j] = cell
}
}
out[i] = row
}
return out, nil
}
func newSheetRangeClearCommand(runner executor.Runner) *cobra.Command {
cmd := newSheetLeaf("clear", "清除工作表指定区域", func(cmd *cobra.Command, _ []string) error {
if err := sheetValidateRequired(cmd, "node", "sheet-id", "range"); err != nil {
@@ -840,10 +927,10 @@ func newSheetFilterCreateCommand(runner executor.Runner) *cobra.Command {
"sheetId": sheetStringFlag(cmd, "sheet-id"),
"range": sheetStringFlag(cmd, "range"),
}
if v := sheetStringFlag(cmd, "criteria"); v != "" {
if sheetStringFlag(cmd, "criteria") != "" {
var criteria []any
if err := json.Unmarshal([]byte(v), &criteria); err != nil {
return fmt.Errorf("--criteria JSON parse failed: %w", err)
if err := sheetParseJSONFlag(cmd, "criteria", &criteria); err != nil {
return err
}
params["criteria"] = criteria
}
@@ -912,10 +999,10 @@ func newSheetFilterViewCreateCommand(runner executor.Runner) *cobra.Command {
"name": sheetStringFlag(cmd, "name"),
"range": sheetStringFlag(cmd, "range"),
}
if v := sheetStringFlag(cmd, "criteria"); v != "" {
if sheetStringFlag(cmd, "criteria") != "" {
var criteria []any
if err := json.Unmarshal([]byte(v), &criteria); err != nil {
return fmt.Errorf("--criteria JSON parse failed: %w", err)
if err := sheetParseJSONFlag(cmd, "criteria", &criteria); err != nil {
return err
}
params["criteria"] = criteria
}
@@ -1577,8 +1664,33 @@ func sheetAddChangedIntParam(cmd *cobra.Command, params map[string]any, key, fla
}
}
// sheetWrapScalarCell converts a bare scalar cell value (string / number /
// bool) into a {type:text,text:"..."} cell object accepted by set_cell_range.
// Returns ok=false for non-scalar values (maps, slices) so the caller can apply
// its richer object validation. This lets `sheet range update` accept the same
// plain `[["a","b"]]` shape as `sheet append` and the wukong update_range tool.
func sheetWrapScalarCell(cell any) (map[string]any, bool) {
switch v := cell.(type) {
case string:
return map[string]any{"type": "text", "text": v}, true
case float64:
return map[string]any{"type": "text", "text": strconv.FormatFloat(v, 'f', -1, 64)}, true
case json.Number:
return map[string]any{"type": "text", "text": v.String()}, true
case bool:
return map[string]any{"type": "text", "text": strconv.FormatBool(v)}, true
}
return nil, false
}
func sheetParseJSONFlag(cmd *cobra.Command, flag string, out any) error {
raw := sheetStringFlag(cmd, flag)
// Resolve @file / @- so large 2D ranges, criteria and sort-keys can come
// from a file or stdin instead of inline shell-quoted JSON. ResolveInputSource
// passes values not starting with "@" through unchanged.
raw, err := cli.ResolveInputSource(sheetStringFlag(cmd, flag), flag, cli.NewStdinGuard())
if err != nil {
return err
}
if err := json.Unmarshal([]byte(raw), out); err != nil {
return fmt.Errorf("--%s JSON parse failed: %w", flag, err)
}
+49
View File
@@ -216,6 +216,55 @@ func sheetCleanCellInfos(v any) {
}
}
// sheetProjectFlatValues walks an MCP get_cell_infos response and, next to any
// rich `cells` array, adds a flat `values` 2D array of each cell's scalar
// `value`. This gives consumers the simple wukong get_range shape
// (values: [["姓名","部门"]]) without dropping the rich `cells` payload.
// Idempotent: skips when `values` already exists or there are no cells.
func sheetProjectFlatValues(v any) {
switch t := v.(type) {
case map[string]any:
if cellsRaw, ok := t["cells"]; ok {
if _, exists := t["values"]; !exists {
if flat := sheetFlatValuesFromCells(cellsRaw); flat != nil {
t["values"] = flat
}
}
}
for _, child := range t {
sheetProjectFlatValues(child)
}
case []any:
for _, child := range t {
sheetProjectFlatValues(child)
}
}
}
func sheetFlatValuesFromCells(cellsRaw any) []any {
rows, ok := cellsRaw.([]any)
if !ok {
return nil
}
out := make([]any, 0, len(rows))
for _, row := range rows {
rowSlice, ok := row.([]any)
if !ok {
return nil
}
flatRow := make([]any, 0, len(rowSlice))
for _, cell := range rowSlice {
if cellMap, ok := cell.(map[string]any); ok {
flatRow = append(flatRow, cellMap["value"])
} else {
flatRow = append(flatRow, cell)
}
}
out = append(out, flatRow)
}
return out
}
func sheetStripCellsMeta(cellsRaw any) {
rows, ok := cellsRaw.([]any)
if !ok {
+27
View File
@@ -52,3 +52,30 @@ func IsEnvelopeSourced(cmd *cobra.Command) bool {
}
return cmd.Annotations[SourceAnnotation] == SourceEnvelope
}
// KindAnnotation records the structural role of a command. It distinguishes a
// pure group container (a heading whose RunE only prints help) from a runnable
// leaf. Both have a RunE set, so cobra's Runnable() cannot tell them apart —
// this annotation can. Kept here next to SourceAnnotation so the literals stay
// in one place.
const KindAnnotation = "dws.kind"
// KindGroup marks a command created as a group container (see NewGroupCommand).
const KindGroup = "group"
// MarkGroup stamps cmd as a group container. Safe on a command without an
// existing Annotations map.
func MarkGroup(cmd *cobra.Command) {
if cmd == nil {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[KindAnnotation] = KindGroup
}
// IsGroup reports whether cmd was created as a group container.
func IsGroup(cmd *cobra.Command) bool {
return cmd != nil && cmd.Annotations[KindAnnotation] == KindGroup
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# Copyright 2026 Alibaba Group
# Licensed under the Apache License, Version 2.0 (the "License").
#
# check-phantom-overrides.py — publish-time guard against "phantom" CLI
# commands: toolOverrides in the discovery envelope whose backing MCP tool is
# not actually deployed on the server. Such overrides render in `dws <svc>
# --help` but fail at invocation ("tool not found"). This is the discovery-side
# (Plan A) complement to the runtime guard in internal/compat/dynamic_commands.go.
#
# TRUTH SOURCE: the live tools/list snapshots written by `dws cache refresh`
# into <cache-dir>/<partition>/tools/*.json, mapped to servers via
# <cache-dir>/<partition>/market/servers.json (cli.id slug -> server key).
#
# An override is a phantom ONLY if its tool name is absent from the resolved
# server's live tool set AND it is not otherwise explained:
# - serverOverride: resolve against the TARGET server's tool set;
# - pipeline: orchestrates multiple tools, no single backing tool;
# - redirectTo/target: a redirect stub, not a real tool invocation;
# - hidden:true: explicitly acknowledged (e.g. lead metadata for a tool
# not yet deployed) — reported as INFO, never fails CI.
#
# Exit code: 1 if any UN-acknowledged phantom override is found, else 0.
#
# Usage:
# dws cache refresh
# python3 scripts/dev/check-phantom-overrides.py
# python3 scripts/dev/check-phantom-overrides.py --envelope envelope/discovery.pre.json
import argparse
import glob
import json
import os
import sys
CLI_META_KEY = "com.dingtalk.mcp.registry/cli"
def load_real_tools(cache_dir, partition):
"""Return (name2tools, slug2tools): display-name and cli.id slug -> set(tool names)."""
base = os.path.join(cache_dir, partition)
real_by_key = {}
for f in glob.glob(os.path.join(base, "tools", "*.json")):
try:
d = json.load(open(f))
except (OSError, ValueError):
continue
names = {(t.get("name") or "").strip() for t in (d.get("tools") or [])}
names.discard("")
real_by_key[d.get("server_key")] = names
servers_path = os.path.join(base, "market", "servers.json")
if not os.path.exists(servers_path):
sys.exit(f"error: {servers_path} not found — run `dws cache refresh` first")
servers = json.load(open(servers_path)).get("servers", [])
name2tools, slug2tools = {}, {}
for s in servers:
ts = real_by_key.get(s.get("key"), set())
if s.get("display_name"):
name2tools[s["display_name"].strip()] = ts
slug = ((s.get("cli") or {}).get("id") or "").strip()
if slug:
slug2tools[slug] = ts
return name2tools, slug2tools, real_by_key
def cli_block(server):
"""toolOverrides live under _meta['com.dingtalk.mcp.registry/cli'] (flat slash key)."""
return (server.get("_meta", {}) or {}).get(CLI_META_KEY, {}) or {}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--envelope", default="envelope/discovery.pre.json")
ap.add_argument("--cache-dir", default=os.path.expanduser("~/.dws/cache"))
ap.add_argument("--partition", default="default_default")
args = ap.parse_args()
if not os.path.exists(args.envelope):
sys.exit(f"error: envelope not found: {args.envelope}")
name2tools, slug2tools, real_by_key = load_real_tools(args.cache_dir, args.partition)
if not real_by_key:
sys.exit("error: no tools snapshots in cache — run `dws cache refresh` first")
env = json.load(open(args.envelope))
phantom, acknowledged = [], []
for s in env.get("servers", []):
cli = cli_block(s)
name = (s.get("server", {}).get("name") or "").strip()
own = name2tools.get(name, set())
ov = cli.get("toolOverrides") or {}
for tool, o in ov.items():
so = (o.get("serverOverride") or "").strip()
if o.get("pipeline") or o.get("redirectTo") or o.get("target"):
continue
target = slug2tools.get(so, set()) if so else own
if tool in target:
continue
entry = (name, tool, o.get("cliName", ""), o.get("group", ""), so or "self")
(acknowledged if o.get("hidden") else phantom).append(entry)
if acknowledged:
print(f"INFO: {len(acknowledged)} acknowledged (hidden:true) phantom overrides — OK:")
for name, tool, cn, g, via in acknowledged:
print(f" [hidden] {name}: {tool} (cli={cn!r} group={g!r} via={via})")
if phantom:
print(f"\nFAIL: {len(phantom)} un-acknowledged phantom override(s) "
f"(tool not deployed; will fail at invocation):")
for name, tool, cn, g, via in phantom:
print(f" {name}: {tool} -> cli={cn!r} group={g!r} via={via}")
print("\nFix: remove the override, route via serverOverride/pipeline, "
"or mark hidden:true if it is intentional lead metadata.")
return 1
print(f"\nOK: no un-acknowledged phantom overrides in {args.envelope}")
return 0
if __name__ == "__main__":
sys.exit(main())
+4 -4
View File
@@ -44,10 +44,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) |
@@ -94,13 +94,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`
@@ -5,7 +5,7 @@
## 专用规则(#8 非 lite 步骤必守)
- **角色类查人优先 label**:用户说"角色为XX的员工/XX角色的员工/XX角色的人员""所有主管/主管理员/财务/HR/总经理"等角色类型人员时,**优先** `contact label list` 获取全部角色 → 匹配目标角色 → `contact label list-members --id <labelId>`;若用户明确指定了角色名称(如"角色为总经理"),则先用 `contact label get --names <XX>` 精确匹配,**若精确匹配无结果,降级 `label list` 模糊匹配**(如用户说"管理员"可匹配到"主管理员"和"子管理员")。
- **角色/职责类查人优先 aisearch**:用户说"角色为XX的员工/XX角色的人员""所有主管/财务/HR/总经理""谁负责 XX"等角色/职责类查询时,**优先** `aisearch person --keyword "<角色或职责>" --dimension duty`(`contact label` 角色查询命令已下线,不再可用)。
- **脚本优先**:按部门拉成员**优先** `python scripts/contact_dept_members.py --query "<部门名>"`(`--dry-run` / `--format json`);失败再 `dept search` → `dept list-members --depts`。
- **详情链路**:用户要子部门、职位、联系方式、汇报关系等,在 `user search` 之后**必须**再 `contact user get --ids <userId>`;禁止仅用 search 的浅表字段交差。
- **`user get` 后部门仍空**:不得过早结束或只建议用户去 App;须在 CLI 能力内尝试 **用户点名的部门** `dept search` + `dept list-members` 等与 `userId` 交叉核对,再结构化汇总「返回中有哪些字段 / 哪些为空及可能原因」。
@@ -20,7 +20,7 @@
## 与其他场景消歧
- **按角色/职位类型查人(主管/管理员/财务等)** → 优先 `contact label list` + `label list-members`;label 精确命中角色维度,返回完整名单;aisearch 是语义模糊搜索不保证完整性。
- **按角色/职位类型查人(主管/管理员/财务等)** → 优先 `aisearch person --dimension duty`(按职责维度找人);`contact label` 角色查询命令已下线。
- **搜人/找人/找同事/查工号/查手机号** → 首选 **`aisearch person`**(AI 语义搜索,支持姓名/部门/职责/上下级/手机号/工号维度),见 `aisearch`(开源版未引入,悟空内部产品)。
- **需要 userId 做后续操作 / 按手机号查 / 按 userId 查详情** → `contact`(精确查询)。
- **纯查部门与子部门成员 / 验证归属 / 组织关系** → `contact`。
@@ -31,7 +31,7 @@
| Recipe | 步骤 |
|--------|------|
| `lookup-label-members` | 1. `contact label list` → 浏览全部角色,匹配目标角色的 labelId<br>2. `contact label list-members --id <labelId>` → 该角色下的成员列表 |
| `lookup-role-members` | 1. `aisearch person --keyword "<角色或职责>" --dimension duty` → 按职责维度找人(`contact label` 已下线) |
| `search-user-by-mobile` | 1. `contact user search-mobile --mobile "<手机号>"` → 按需 `contact user get --ids <userId>` |
| `lookup-dept-id` | 1. `contact dept search --query "<部门关键词>"` → 回显 `deptId`(多命中须消歧) |
| `list-subdepts` | 1. 已有父 `deptId` → `contact dept list-children --dept <父deptId>` 直接取直属子部门列表<br>2. 只有部门名 → 先 `lookup-dept-id` 取 `deptId`,再 `list-children` |
+2 -2
View File
@@ -82,10 +82,10 @@
**用 `report` 的场景**:
- "帮我看看收到的日报" — 日志收件箱
- "帮我写/提交今天的日报(钉钉日志模版)" — 先 `report template list` / `template detail`,再 `report create`
- "帮我写/提交今天的日报(钉钉日志模版)" — 先 `report template list` / `template get`,再 `report entry submit --contents-file <tmp>.json`
- "有什么日志模版" — 查看模版
- "看看这个日志的已读统计" — 阅读状态
- "我发过的日志有哪些" — 已发送列表 (`report sent`)
- "我发过的日志有哪些" — 已发送列表 (`report outbox list`)
- 用户提到"日报"、"周报"、"日志"
**用 `todo` 的场景**:
File diff suppressed because it is too large Load Diff
+22 -22
View File
@@ -101,8 +101,6 @@ Flags:
**默认行为**:不传 `--start` / `--end` 时,默认返回今天的日程(00:00:00 ~ 23:59:59)。
**权限**:查询共享日历下的日程时,至少要有reader权限。
补充:当前用户的个人日程也可用 `dws calendar event list-mine` 查询,参数与 `event list` 一致。
### 获取日程详情
```
Usage:
@@ -219,12 +217,13 @@ Flags:
Usage:
dws calendar attendee add [flags]
Example:
dws calendar attendee add --event <EVENT_ID> --attendees <USER_ID_1>,<USER_ID_2>
dws calendar attendee add --event <EVENT_ID> --attendees <USER_ID> --optional
dws calendar attendee add --event <EVENT_ID> --users <USER_ID_1>,<USER_ID_2>
dws calendar attendee add --event <EVENT_ID> --users <USER_ID> --optional true
Flags:
--event string 日程 ID (必填)
--attendees string 参会人 userId 列表,逗号分隔 (必填,最多500人)
--optional 参会人可选 (默认必选参会人)
--event string 日程 eventId (必填)
--users string 参会人 userId 列表,逗号分隔 (必填)
--optional string 是否可选参会人 (可选)
--calendar-id string 日历 ID (可选)
```
### 移除参会人
@@ -235,10 +234,11 @@ Flags:
Usage:
dws calendar attendee delete [flags]
Example:
dws calendar attendee delete --event <EVENT_ID> --attendees <USER_ID> --yes
dws calendar attendee delete --event <EVENT_ID> --users <USER_ID> --yes
Flags:
--event string 日程 ID (必填)
--attendees string 参会人 userId 列表,逗号分隔 (必填)
--event string 日程 eventId (必填)
--users string 参会人 userId 列表,逗号分隔 (必填)
--calendar-id string 日历 ID (可选)
```
### 搜索会议室
@@ -317,10 +317,10 @@ Usage:
dws calendar room list-groups [flags]
Example:
dws calendar room list-groups
dws calendar room list-groups --limit 20 --page 0
dws calendar room list-groups --page-size 20 --page-index 0
Flags:
--limit string 页大小 (可选,不填默认 100,超过 100 按 100 处理)
--page string 分页起始位置 (可选,不填默认 0)
--page-size string 页大小 (可选,默认 100,上限 100)
--page-index string 分页起始位置 (可选,默认 0)
```
### 添加日程附件
@@ -419,8 +419,8 @@ Flags:
用户说"参会人/与会者":
- 查看 → `attendee list`
- 邀请/添加 → `attendee add --attendees <USER_ID>`(可选参会人加 `--optional`)
- 移除 → `attendee delete --attendees <USER_ID>`
- 邀请/添加 → `attendee add --users <USER_ID>`(可选参会人加 `--optional true`)
- 移除 → `attendee delete --users <USER_ID>`
用户说"会议室/订会议室":
- 哪个空闲 → `room search`
@@ -479,7 +479,7 @@ dws calendar event create --title "Q1 复盘会" \
--start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15:00:00+08:00" --format json
# Step 2: 添加参会人(必须用 Step 1 返回的 eventId)
dws calendar attendee add --event <EVENT_ID> --attendees userId1,userId2 --format json
dws calendar attendee add --event <EVENT_ID> --users userId1,userId2 --format json
# Step 3: 搜索空闲会议室
dws calendar room search --start ... --end ... --format json
@@ -519,19 +519,19 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
- 时间格式: `event create/update`、`event list`、`busy search` 和 `event suggest` 用 ISO-8601
- 时区: `event create/update` 和 `event suggest` 支持 `--timezone` 指定 IANA 时区(如 `Asia/Shanghai`、`America/New_York`),不传默认 `Asia/Shanghai`
- 创建日程时可通过 `--attendees` 直接指定参会人(最多500人),也可创建后用 `attendee add --attendees ...` 单独添加
- `--attendees` 和 `--open-dingtalk-ids` 至少传一个(如果需要指定参会人)
- 添加参会人时可通过 `--optional` 设为可选参会人(默认必选)
- 创建日程时可通过 `--attendees` 直接指定参会人(最多500人),也可创建后用 `attendee add --users ...` 单独添加
- `event create` 的 `--attendees` 和 `--open-dingtalk-ids` 至少传一个(如果需要指定参会人)
- `attendee add` 可通过 `--optional true` 设为可选参会人(默认必选)
- `event suggest` 根据参会人闲忙自动推荐合适时间,适合会议时间未确定时使用
- 创建日程**支持**通过 `--rooms` 一步预定会议室(`event create --rooms roomId1,roomId2`);若创建后再加,仍可用 `room add`
- `room search` 不带 `--group-id` 时查根目录;企业会议室超过 100 条会报错,此时需先 `room list-groups` 获取分组,再按分组逐一查询
- `room list-groups` 支持 `--limit` / `--page` 分页(schema 类型为字符串)
- `room list-groups` 支持 `--page-size` / `--page-index` 分页(schema 类型为字符串)
- **`event create --rooms` / `room add --rooms` 的唯一合法来源**:最近一次(同一会话、同一时段窗口)`room search` 返回体中的 `roomId`;禁止把用户自然语言会议室名当 `roomId` 传入(否则会 `roomId invalid` 等错误)
- **搜房无结果**:在符合早停/用户限定范围内,`room search`(含按分组逐组查)全部返回空或无空闲 → 应**直接向用户报错/说明失败**并结束订房;**禁止**假设 roomId、禁止无合法 `roomId` 时调用 `room add` / `event create --rooms` 试探、禁止用 `event get` 等绕路推断 roomId
- **评测 / 自动化断言**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId`
- **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `<fileId>:<name>` 元素逗号分隔
- **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历
- **已弃用入参**:`--max-results`(event list / list-mine)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用
- **已弃用入参**:`--max-results`(event list)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用
## 自动化脚本
@@ -544,4 +544,4 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
## 相关产品
- [conference](./simple.md) — 仅视频会议预约(返回入会链接),不含参会人/会议室管理
- [contact](./contact.md) — 搜索同事 userId,用于 attendee add --attendees
- [contact](./contact.md) — 搜索同事 userId,用于 attendee add --users
+21 -39
View File
@@ -18,9 +18,9 @@ Flags:
#### 查看群成员列表 — 分页查询指定群聊的成员
```
Usage:
dws chat group members [flags]
dws chat group members list [flags]
Example:
dws chat group members --id <openconversation_id>
dws chat group members list --id <openconversation_id>
Flags:
--cursor string 分页游标,首次从 0 开始
--id string 群 ID / openconversation_id (必填)
@@ -100,13 +100,11 @@ Usage:
dws chat group transfer-owner [flags]
Example:
dws chat group transfer-owner --group <openConversationId> --new-owner <openDingTalkId>
dws chat group transfer-owner --group <openConversationId> --user <userId>
# 查询群 ID: dws chat search --query "群名"
# 查询人员: dws aisearch person --keyword "姓名" --dimension name
Flags:
--group string 群聊 openConversationId (必填)
--new-owner string 新群主 openDingTalkId
--user string 新群主 userId
--new-owner string 新群主 openDingTalkId (必填)
```
#### 获取群邀请链接 — 获取指定群聊的邀请加入链接
@@ -511,28 +509,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 +1166,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 +1338,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 +1357,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 +1449,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 +1496,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)
@@ -1553,5 +1535,5 @@ Flags:
## 相关产品
- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、send-by-bot --users、send-by-bot --at-user-ids、list-by-sender --sender-user-id;获取 openDingTalkId 用于 message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、send-by-bot --open-dingtalk-ids、send-by-bot --at-open-dingtalk-ids、list-by-sender 的 --sender-open-dingtalk-id
- [contact](./contact.md) — 搜索同事/好友,获取 userId 用于 --user、send-by-bot --users、list-by-sender --sender-user-id;获取 openDingTalkId 用于 message send 的 --at-open-dingtalk-ids、--open-dingtalk-id、list-by-sender 的 --sender-open-dingtalk-id
- [drive](./drive.md) — 上传文件获取下载链接,用于 Markdown 图片/文件消息
+13 -86
View File
@@ -1,7 +1,8 @@
# 通讯录 (contact) 命令参考
> **CRITICAL — 命令合法性**:contact 只有 `user` / `dept` / `label` / `relation` 四个二级子命令。
> **CRITICAL — 命令合法性**:contact 只有 `user` / `dept` / `relation` 三个可操作的二级子命令。
> 不存在 `contact search`、`contact find`、`contact list`、`contact get`、`contact user find/list`。
> 角色/标签(label)查询命令已下线,不再提供 `contact label list/get/list-members`。
> 构造命令前必须确认路径在下方「命令总览」中存在;不确定时,**根据意图对照下方「意图判断」选择正确命令**。
>
> **CRITICAL — 根部门**:钉钉根部门 `deptId=1`。`dept` 系列命令查根部门统一传 `--dept 1` 或 `--depts 1`,不要传 `self / me / root / 0`。
@@ -187,52 +188,6 @@ Notes:
- 跨层级成员展开见 [08-directory.md](../best_practices/08-directory.md) 的 `cross-level-dept-members` recipe
```
### label (角色查询)
> **角色ID = labelId**:用户提到"角色ID"时,均指通讯录 label 系统中的角色ID,**不是部门ID也不是userId**。查角色成员用 `label list-members --id <角色ID>`,不要传给 `dept get-info` 或 `user get`。
#### 获取企业所有角色列表
```
Usage:
dws contact label list
Example:
dws contact label list
Flags:
无
Notes:
- 无需参数,返回当前企业全部角色列表(labelId、labelName等)
- 用于不知道准确角色名称时先浏览全部角色
- 典型场景:用户说“企业所有主管/查所有管理员/财务人员有哪些”→ 先 label list 浏览全部角色,匹配目标角色后 label list-members 获取成员
```
#### 根据角色名称查询角色
```
Usage:
dws contact label get [flags]
Example:
dws contact label get --names "管理员"
dws contact label get --names "管理员,财务"
Flags:
--names string 角色名称,逗号分隔 (必填)
Notes:
- 精确匹配角色名称,不支持模糊搜索
- 支持同时查询多个角色名称,逗号分隔
- 无需分页
```
#### 查询角色下的成员
```
Usage:
dws contact label list-members [flags]
Example:
dws contact label list-members --id 12345
Flags:
--id string 角色 ID (必填)
Notes:
- 根据角色ID直接查询成员列表;已有角色ID时直接用 `--id <labelId>`
- 不知道角色ID时:先 `dws contact label get --names "角色名"` 或 `dws contact label list` 获取 labelId
```
## 意图判断
> **搜人首选 `aisearch person`**:凡是“找人/搜人/找同事/谁负责/上级/下级”均优先用 [aisearch person](./aisearch.md),以下场景才用 contact。
@@ -247,32 +202,19 @@ Notes:
用户说"部门详情/部门信息/部门多少人" → `dept get-info`(返回部门ID、部门名称、部门人数;需 deptId,若只有部门名称需先 `dept search`)
用户说"子部门/下设部门/部门有哪些下级部门/枚举二级部门" → `dept list-children`(需父 deptId;只有部门名先 `dept search`)
用户说"部门有谁/部门成员/人员名单" → `dept list-members`(需 deptId;**仅本部门不含下级**,含下级先 `dept list-children` 再合并查)
用户查询涵盖"角色"(主管/管理员/财务/HR/总经理等任意角色名)→ 统一走 `contact label` 链路,按下方决策树选命令:
- 不知道角色名 / 枚举所有角色 → `label list`
- 已知角色名,查ID或成员 → 先 `label get --names <名>` 拿ID,查成员再调 `label list-members --id <ID>`;**精确匹配无结果时降级 `label list` 模糊匹配**
- 已知角色ID 查成员 → `label list-members --id <ID>`
用户查询涵盖"角色/职责"(主管/管理员/财务/HR/总经理/谁负责 XX 等)→ 优先用 [`aisearch person`](./aisearch.md) 按职责维度找人(`dws aisearch person --keyword "<角色或职责>" --dimension duty`)。
> [!IMPORTANT]
> **角色查询 3 步决策树**(不依赖字串匹配,按语义判定):
> 1. 不知道角色名 / 要枚举所有角色(列出企业有哪些角色、每个角色的名称和ID、不确定叫什么角色、负责XX的人有哪些等) → `label list`
> 2. 已知角色名 要查角色ID → `label get --names`
> 3. 已知角色ID 要查成员 → `label list-members --id`
>
> 任何含"角色"一词的查询默认走 `contact label` 链路。**唯一例外**:终点是"某个人是否具备某权限"(如"张三是不是管理员")→ `user get`。禁止路由到 `user profile fields`(那是花名册字段)、`dept list-members` 筛选、或 OA/chat 模块。
> [!IMPORTANT]
> **角色查人 vs 查某人的角色信息 — 判断口径**:先判断用户的终点是"人"还是"属性":
> - 终点是**人**("管理员角色有哪些人""管理员下都有谁""查XX角色的成员")→ 角色维度查人,**必须**走 `contact label` 链路(`label get`/`label list` → `label list-members`),**禁止**通过 `dept list-members` 筛选 `isAdmin` 等字段替代
> **角色查人 vs 查某人的属性 — 判断口径**:先判断用户的终点是"人"还是"属性":
> - 终点是**人**("管理员有哪些人""谁负责财务""找 XX 角色的成员")→ 走 [`aisearch person`](./aisearch.md)(职责维度找人)
> - 终点是**属性**("张三是不是管理员""查某人的主管/管理员权限")→ 已知 userId 查个人详情,走 `user get`(返回 isAdmin/leader 等字段)
>
> 反例对照:
> - "管理员角色下都有哪些人" → `label get --names 管理员` → `label list-members`(终点=角色下的**人员列表**)
> - "我想知道管理员这个角色下都有谁" → `label get --names 管理员` → `label list-members`(终点=角色下的**人员列表**)
> - "管理员都有哪些人" → `aisearch person --keyword 管理员 --dimension duty`(终点=人员列表)
> - "张三是不是管理员" → `user get --ids <userId>`(终点=某个人的**属性**)
> - "查一下张三的管理员权限" → `user get --ids <userId>`(终点=某个人的**属性**)
> - "角色ID为55808858的角色下有哪些成员" → `label list-members --id 55808858`(已有角色ID直接查成员)
>
> **角色 = 通讯录 label**:用户提到"角色"(角色ID/角色成员/角色名称/企业角色/查角色下的人)时,均指通讯录组织角色,应走 `contact label` 链路。OA 审批只管审批流程(待审批/同意/拒绝),**不支持**查询角色成员;群角色(chat group-role)只管群内身份,不涉及企业组织角色。
> 注意:`contact label` 角色查询命令已下线,不要再构造 `contact label list/get/list-members`。OA 审批只管审批流程(待审批/同意/拒绝),**不支持**查询角色成员;群角色(chat group-role)只管群内身份,不涉及企业组织角色。
用户说"我关注了谁/我的特别关注列表/我的星标联系人/特别关注的人有哪些" → `relation list-my-followings`
> [!IMPORTANT]
@@ -312,28 +254,19 @@ dws contact dept list-children --dept <父deptId> --format json
# 6. 查看部门成员
dws contact dept list-members --depts <deptId> --format json
# 7. 获取企业所有角色列表 — 不知道角色名时先浏览
dws contact label list --format json
# 8. 根据角色名称查询角色
dws contact label get --names "管理员" --format json
# 9. 查询角色下的成员
dws contact label list-members --id <labelId> --format json
# 10. 查询花名册有权限的字段列表
# 7. 查询花名册有权限的字段列表
dws contact user profile fields --format json
# 11. 根据字段 code 查询指定员工的花名册信息
# 8. 根据字段 code 查询指定员工的花名册信息
dws contact user profile get --staff-id <STAFF_ID> --fields fieldCode1,fieldCode2 --format json
# 12. 查询所有可见字段的花名册信息
# 9. 查询所有可见字段的花名册信息
dws contact user profile get --staff-id <STAFF_ID> --format json
# 13. 查询全部离职员工
# 10. 查询全部离职员工
dws contact user dismission search --format json
# 14. 按姓名/时间范围/部门筛选离职员工
# 11. 按姓名/时间范围/部门筛选离职员工
dws contact user dismission search --name "张三" --format json
dws contact user dismission search --start 2026-01-01 --end 2026-03-31 --format json
dws contact user dismission search --depts 123456,789012 --hide-retirement=false --format json
@@ -347,8 +280,6 @@ dws contact user dismission search --depts 123456,789012 --hide-retirement=false
| `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) |
| `user get-self/search` | `userId` | profile get 的 --staff-id |
| `user profile fields` | `fieldCode` | profile get 的 --fields |
| `label list` | `labelId` / `labelName` | `label get --names` 或 `label list-members --id` |
| `label get` | `labelId` | `label list-members` 的 --id |
| `dept search/list-children` | `deptId` | dept get-info/list-children/list-members 的 --dept/--depts |
| `dept search/list-children` | `deptId` | dismission search 的 --depts |
@@ -362,11 +293,7 @@ dws contact user dismission search --depts 123456,789012 --hide-retirement=false
- 建议先执行 `user profile fields` 获取可用字段列表,再根据需要的字段 code 执行 `user profile get`
- `user dismission search` 的 `--start`/`--end` 必须同时设置或同时不设置,不允许只传其中一个
- `user dismission search` 默认隐藏退休人员(`--hide-retirement` 默认 true),默认展示合作伙伴(`--hide-partner` 默认 false)
- `label list` 无需参数,适用于不知道准确角色名称的场景;当用户说“查所有主管/主管理员/财务”等角色类型人员时,优先 `label list` 列出企业全部角色,LLM 灵活匹配目标角色后调用 `label list-members`
- 角色类查询(主管、管理员、财务、HR 等任意角色)优先走 label 链路,而非 dept list-members 或 aisearch person;label 精确命中角色维度,返回完整名单
- `label get` 是精确匹配角色名称,不支持模糊搜索;支持逗号分隔同时查询多个角色名称
- **`label get` 精确匹配无结果时的降级策略**:若 `label get --names "XX"` 返回空结果,必须降级调用 `label list` 获取全部角色列表,从中模糊匹配包含XX关键词的角色(如用户说"管理员"可匹配到"主管理员"和"子管理员"),再对匹配到的角色调用 `label list-members`
- `label list-members` 需要先通过 `label list` 或 `label get` 获取 labelId,再用 --id 查询角色下的成员
- 角色/职责类查询(主管、管理员、财务、HR 等任意角色,或"谁负责 XX")优先走 [`aisearch person`](./aisearch.md) 的职责维度(`--dimension duty`);`contact label` 角色查询命令已下线,不要再构造
## 自动化脚本
-27
View File
@@ -26,36 +26,10 @@ Flags:
--robot-code string 机器人 ID (必填, 或设 DINGTALK_DING_ROBOT_CODE)
```
### 查询 DING 消息历史
```
Usage:
dws ding message list [flags]
Example:
dws ding message list
dws ding message list --type UNREAD
dws ding message list --type SEND --cursor 10
Flags:
--cursor int 分页游标 (首次传 0, 翻页传返回的 nextCursor)
--type string 消息类型: ALL / UNREAD / SEND / NEW_COMMENT / DELETED (可选, 不传返回全部)
```
### 查看 DING 接收状态
```
Usage:
dws ding message receiver-status [flags]
Example:
dws ding message receiver-status --ding-id <OPEN_DING_ID>
# 查询 dingId: dws ding message list
Flags:
--ding-id string DING 消息 openDingId (必填)
```
## 意图判断
用户说"DING 一下/紧急通知/电话提醒" → `message send`
用户说"撤回 DING" → `message recall`
用户说"DING 消息/查 DING/DING 历史/我的 DING" → `message list`
用户说"DING 接收状态/谁收到了 DING/DING 已读" → `message receiver-status`
关键区分:
- ding(紧急提醒, 支持电话/短信) vs bot(常规群/单聊消息)
@@ -77,7 +51,6 @@ dws ding message recall --robot-code <ROBOT_CODE> --id <OPEN_DING_ID> --format j
| 操作 | 提取 | 用于 |
|------|------|------|
| `message send` | `openDingId` | message recall 的 --id |
| `message list` | `openDingId` | message receiver-status 的 --ding-id |
## 注意事项
- `--robot-code` 从钉钉开放平台 **应用管理 → 机器人** 中获取,也可设环境变量 `DINGTALK_DING_ROBOT_CODE`
- sms/call 类型有通信费用,使用前需和用户确认
+9 -9
View File
@@ -77,19 +77,19 @@ dws doc media --help
| `doc comment list/create/reply/create-inline` | 文档评论与划词评论 | `--node` (+ ...) | [`doc/doc-comment.md`](./doc/doc-comment.md) |
| `doc export` / `doc export get` | 在线文档导出 docx | `--node` `--output` | [`doc/doc-export.md`](./doc/doc-export.md) |
### 文件操作(已迁移,以下命令 deprecated)
### 文件操作(doc 与 drive 均提供,按场景选用)
> **迁移提示**:文件管理命令已按产品领域架构重新归属,旧命令在过渡期内仍可使用,运行时会输出 deprecated 警告。
> **说明**:以下命令在 `doc` 下均为可用命令;`drive` 下也有等价命令(聚合钉盘 + 文档空间)。需要纯文件/钉盘语义时用 `drive`,需要在文档空间内操作时用 `doc`,两者都不会报 deprecated。
| 旧命令 | 推荐命令 | 详见 |
|--------|----------|------|
| doc 命令 | drive 等价命令 | 详见 |
|----------|----------------|------|
| `doc upload` | `dws drive upload` | [`drive.md`](./drive.md) |
| `doc download` | `dws drive download` | [`drive.md`](./drive.md) |
| `doc copy` | `dws drive copy` | [`drive.md`](./drive.md) |
| `doc move` | `dws drive move` | [`drive.md`](./drive.md) |
| `doc rename` | `dws drive rename` | [`drive.md`](./drive.md) |
| `doc delete` | `dws drive delete` | [`drive.md`](./drive.md) |
| `doc folder create` | `dws drive folder create` | [`drive.md`](./drive.md) |
| `doc folder create` | `dws drive mkdir` | [`drive.md`](./drive.md) |
| `doc file create` | `dws wiki node create --type <type>` | [`wiki.md`](./wiki.md) |
| `doc permission *` | `dws drive permission *` | [`drive.md`](./drive.md) |
| `doc list` | `dws drive list --workspace` / `dws wiki node list` | [`drive.md`](./drive.md) / [`wiki.md`](./wiki.md) |
@@ -120,9 +120,9 @@ dws doc media --help
| 插入富 block(callout / 分栏 / 表格) | [`doc-block.md`](./doc/doc-block.md) + [`format/doc-jsonml-cookbook.md`](./doc/format/doc-jsonml-cookbook.md) + [`style/doc-style-guideline.md`](./doc/style/doc-style-guideline.md) | block insert |
| 上传图片 / 附件 | [`doc-media.md`](./doc/doc-media.md) | media insert |
| 评论 / 划词评论(含 @人) | [`doc-comment.md`](./doc/doc-comment.md)(+ `dws contact user search` 取 mention 用 userId) | comment create |
| 文档分享 / 节点级权限 | [`drive.md`](./drive.md)(已迁移:`dws drive permission add/update/list/remove`) | `drive permission` |
| 文档分享 / 节点级权限 | [`drive.md`](./drive.md)(推荐 `dws drive permission add/update/list/remove`;`doc permission *` 亦可用) | `drive permission` |
| 导出 PDF / DOCX | [`doc-info.md`](./doc/doc-info.md) + [`doc-export.md`](./doc/doc-export.md) | export |
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`drive.md`](./drive.md)(已迁移:`dws drive upload/download/copy/move/rename/delete`) | `drive *` |
| 文件下载 / 上传 / 移动 / 重命名 / 复制 | [`drive.md`](./drive.md)(推荐 `dws drive upload/download/copy/move/rename/delete`;同名 `doc` 命令亦可用) | `drive *` |
## 意图判断
@@ -284,12 +284,12 @@ dws doc block list --node <DOC_ID> --format json # 划词需先
dws doc comment create-inline --node <DOC_ID> --block-id <BLOCK_ID> \
--start 0 --end 10 --content "建议调整" --selected-text "原文" --format json
# ── 工作流 8: 权限授予(节点级,已迁移到 drive)──
# ── 工作流 8: 权限授予(节点级,推荐用 drive,doc permission 亦可用)──
dws contact user search --query "张三" --format json # 取 userId
dws drive permission add --node <DOC_ID> --user <uid1>,<uid2> --role EDITOR --format json
dws drive permission list --node <DOC_ID> --format json # 校验
# ── 工作流 9: 文件操作(已迁移到 drive)──
# ── 工作流 9: 文件操作(推荐用 drive,同名 doc 命令亦可用)──
# 第一步:获取 nodeId
# 方式 A(优先):用户直接提供 URL / nodeId → 直接传 --node
# 方式 B:按关键字找:dws drive search --query "项目周报" --format json
@@ -15,11 +15,11 @@ Usage:
Example:
dws doc comment list --node <DOC_ID>
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
dws doc comment list --node <DOC_ID> --limit 20 --cursor <TOKEN>
dws doc comment list --node <DOC_ID> --page-size 20 --next-token <TOKEN>
Flags:
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
--limit int 每页返回的评论数量,默认 50,最大 50
--cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
--page-size int 每页返回的评论数量
--next-token string 下一页 token,从上一次请求的返回结果中获取 (首次请求不传)
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
```
@@ -13,19 +13,19 @@ Usage:
Example:
dws doc list
dws doc list --folder <DOC_FOLDER_NODE_ID>
dws doc list --workspace <WS_ID> --limit 20
dws doc list --workspace <WS_ID> --page-size 20
Flags:
--folder string 文档文件夹 nodeId 或 alidocs 文件夹 URL;不要传 drive dentryId/parent-id 这类纯数字 ID
--workspace string 知识库 ID
--limit int 每页数量 (默认 50,最大 50)
--cursor string 分页游标 (从上次结果的 nextPageToken 获取)
--page-size int 每页数量
--page-token string 分页 token (从上次结果的 nextPageToken 获取)
```
## 关键说明
- 不传任何 flag 时遍历"我的文档"根目录。
- `--folder` 仅接受文档文件夹 `nodeId` / `dentryUuid` / alidocs 文件夹 URL;**禁止**传入 drive `dentryId`、`parentId`、`spaceId` 这类纯数字 ID。
- 单页最多 50 条,需翻页时使用 `nextPageToken` → `--cursor`。
- 需翻页时使用 `nextPageToken` → `--page-token`。
## 上下文传递
@@ -45,10 +45,10 @@ dws doc list --folder <DOC_FOLDER_NODE_ID> --format json
dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --format json
# 浏览指定知识库根目录(取出 workspaceId 后)
dws doc list --workspace <WS_ID> --limit 20 --format json
dws doc list --workspace <WS_ID> --page-size 20 --format json
# 翻页
dws doc list --folder <DOC_FOLDER_NODE_ID> --cursor <nextPageToken> --format json
dws doc list --folder <DOC_FOLDER_NODE_ID> --page-token <nextPageToken> --format json
```
## 参考
@@ -31,8 +31,8 @@ Flags:
--editor-uids strings 按编辑者用户 ID 过滤,逗号分隔
--mentioned-uids strings 按 @提及的用户 ID 过滤,逗号分隔
--workspace-ids strings 按知识库 ID 过滤,支持知识库 URL,逗号分隔
--limit int 每页数量 (默认 10,最大 30)
--cursor string 分页游标 (从上次结果的 nextPageToken 获取)
--page-size int 每页数量
--page-token string 分页 token (从上次结果的 nextPageToken 获取)
```
## 关键说明
@@ -70,7 +70,7 @@ dws doc search --creator-uids uid1,uid2
dws doc search --workspace-ids wsId1,wsId2
# 翻页
dws doc search --query "周报" --limit 30 --cursor <nextPageToken>
dws doc search --query "周报" --page-size 30 --page-token <nextPageToken>
```
## 参考
+36 -36
View File
@@ -96,13 +96,13 @@ dws contact user search --keyword "姓名" --format json
Usage:
dws mail message search [flags]
Example:
dws mail message search --email user@company.com --query "subject:\"周报\"" --size 20
dws mail message search --email user@company.com --query "from:alice AND date>2025-06-01T00:00:00Z" --size 10
dws mail message search --email user@company.com --query "subject:\"周报\"" --limit 20
dws mail message search --email user@company.com --query "from:alice AND date>2025-06-01T00:00:00Z" --limit 10
Flags:
--cursor string 邮件的起始偏移标识, 其值取自响应中的nextCursor字段。""表示从头开始
--email string 搜索目标邮箱地址 (必填)
--query string KQL 查询表达式 (必填), 其中 date 格式需遵循 ISO8601 规范
--size string 每页返回数量(最大限制 100, 默认 20),别名: --limit, --page-size
--limit string 每页返回数量(最大限制 100, 默认 20)
```
KQL 查询字段: date, size, tag, folderId, isRead, hasAttachments, subject, attachname, body, from, to
@@ -140,9 +140,9 @@ KQL 查询字段: date, size, tag, folderId, isRead, hasAttachments, subject, at
**翻页示例:**
```bash
# 第一页
dws mail message search --email user@company.com --query "folderId:2" --size 20 --format json
dws mail message search --email user@company.com --query "folderId:2" --limit 20 --format json
# 取返回中的 nextCursor,传入下一次请求(nextCursor="$" 时停止)
dws mail message search --email user@company.com --query "folderId:2" --size 20 --cursor <nextCursor> --format json
dws mail message search --email user@company.com --query "folderId:2" --limit 20 --cursor <nextCursor> --format json
```
### 查看邮件完整内容
@@ -168,17 +168,17 @@ Usage:
dws mail message send [flags]
Example:
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "本周完成任务A和任务B"
--subject "周报" --content "本周完成任务A和任务B"
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "见附件" --attachment ./report.pdf
--subject "周报" --content "见附件" --attachment ./report.pdf
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "见附件" --attachment ./a.pdf --attachment ./b.xlsx
--subject "周报" --content "见附件" --attachment ./a.pdf --attachment ./b.xlsx
dws mail message send --from user@company.com --to colleague@company.com \
--subject "图表周报" --body "图表如下:[inline:chart.png]" --inline-attachment ./chart.png
--subject "图表周报" --content "图表如下:[inline:chart.png]" --inline-attachment ./chart.png
dws mail message send --from user@company.com --to colleague@company.com \
--subject "带图文档" --body "见附件,图表:[inline:img.png]" --attachment ./doc.pdf --inline-attachment ./img.png
--subject "带图文档" --content "见附件,图表:[inline:img.png]" --attachment ./doc.pdf --inline-attachment ./img.png
Flags:
--body string 邮件正文 (必填)
--content string 邮件正文 (必填)
--cc string 抄送人列表
--from string 发件人邮箱 (必填),别名: --sender
--subject string 邮件标题 (必填)
@@ -202,7 +202,7 @@ Flags:
- 仅支持图片类型:`jpg` / `jpeg` / `png` / `gif` / `webp` / `bmp` / `svg`
- CLI 自动生成 contentId,格式:`inline-{文件名(不含扩展名)}-{序号}@alimail.com`,例:`inline-chart-1@alimail.com`
- 在 `--body` 中使用占位符 `[inline:文件名]` 引用图片,CLI 自动替换为 `<img src="cid:...">` 标签
- 在 `--content` 中使用占位符 `[inline:文件名]` 引用图片,CLI 自动替换为 `<img src="cid:...">` 标签
- 若 body 中没有对应占位符,内联图片会自动追加到正文末尾
- 非图片类型(PDF、视频、音频等)请改用 `--attachment`
@@ -339,13 +339,13 @@ Usage:
dws mail message reply [flags]
Example:
dws mail message reply --from user@company.com --id <messageId>
dws mail message reply --from user@company.com --id <messageId> --subject "Re: 周报" --body "已收到,谢谢!"
dws mail message reply --from user@company.com --id <messageId> --subject "Re: 周报" --content "已收到,谢谢!"
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--to string 收件人列表(可选)
--id string 要回复的邮件 ID (必填)
--subject string 回复邮件标题(可选)
--body string 回复正文(可选)
--content string 回复正文(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
@@ -371,13 +371,13 @@ Usage:
dws mail message reply-all [flags]
Example:
dws mail message reply-all --from user@company.com --id <messageId>
dws mail message reply-all --from user@company.com --id <messageId> --subject "Re: 周报" --body "感谢大家的参与!"
dws mail message reply-all --from user@company.com --id <messageId> --subject "Re: 周报" --content "感谢大家的参与!"
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--to string 收件人列表(可选,包含发件人及所有原始收件人)
--id string 要回复的邮件 ID (必填)
--subject string 回复邮件标题(可选)
--body string 回复正文(可选)
--content string 回复正文(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
@@ -409,7 +409,7 @@ Flags:
--to string 转发收件人列表(可选)
--id string 要转发的邮件 ID (必填)
--subject string 转发邮件标题(可选)
--body string 转发附言(可选)
--content string 转发附言(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
@@ -461,27 +461,27 @@ Usage:
dws mail draft create [flags]
Example:
dws mail draft create --from user@company.com --to colleague@company.com \
--subject "草稿标题" --body "草稿正文"
--subject "草稿标题" --content "草稿正文"
dws mail draft create --from user@company.com --subject "草稿标题"
dws mail draft create --from user@company.com --subject "带附件草稿" \
--body "见附件" --attachment ./report.pdf
--content "见附件" --attachment ./report.pdf
dws mail draft create --from user@company.com --subject "带图片草稿" \
--body "图表:[inline:chart.png]" --inline-attachment ./chart.png
--content "图表:[inline:chart.png]" --inline-attachment ./chart.png
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--subject string 邮件标题 (必填)
--to string 收件人列表(可选,有确定收件人时才传)
--cc string 抄送人列表(可选,有确定抄送人时才传)
--body string 邮件正文(可选,有正文内容时才传)
--content string 邮件正文(可选,有正文内容时才传)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
> **注意:** `--to`、`--cc`、`--body` 均为可选参数,**仅在用户明确提供对应信息时才传入**。若用户未指定收件人,不要传 `--to ""`(空字符串)。
> **注意:** `--to`、`--cc`、`--content` 均为可选参数,**仅在用户明确提供对应信息时才传入**。若用户未指定收件人,不要传 `--to ""`(空字符串)。
**附件说明:**
指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动完成草稿创建和附件上传,**草稿保留在草稿箱,不会发送**。内联图片用法同 `message send`(`--body` 中使用 `[inline:文件名]` 占位符)。
指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动完成草稿创建和附件上传,**草稿保留在草稿箱,不会发送**。内联图片用法同 `message send`(`--content` 中使用 `[inline:文件名]` 占位符)。
**返回字段:**
@@ -494,24 +494,24 @@ Flags:
Usage:
dws mail draft update [flags]
Example:
dws mail draft update --from user@company.com --id <messageId> --subject "新标题" --body "新正文"
dws mail draft update --from user@company.com --id <messageId> --body "见附件" --attachment ./report.pdf
dws mail draft update --from user@company.com --id <messageId> --subject "新标题" --content "新正文"
dws mail draft update --from user@company.com --id <messageId> --content "见附件" --attachment ./report.pdf
dws mail draft update --from user@company.com --id <messageId> \
--body "图表:[inline:chart.png]" --inline-attachment ./chart.png
--content "图表:[inline:chart.png]" --inline-attachment ./chart.png
Flags:
--from string 发件人邮箱 (必填),别名: --sender
--id string 草稿邮件 ID (必填)
--to string 收件人列表(可选)
--cc string 抄送人列表(可选)
--subject string 邮件标题(可选)
--body string 邮件正文(可选)
--content string 邮件正文(可选)
--attachment stringArray 附件文件路径,可多次指定 (可选)
--inline-attachment stringArray 内联图片路径,可多次指定,cid 自动生成 (可选)
```
**附件说明:**
指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动完成草稿更新和附件上传,**草稿保留在草稿箱,不会发送**。内联图片用法同 `message send`(`--body` 中使用 `[inline:文件名]` 占位符)。
指定 `--attachment` 或 `--inline-attachment` 时,CLI 自动完成草稿更新和附件上传,**草稿保留在草稿箱,不会发送**。内联图片用法同 `message send`(`--content` 中使用 `[inline:文件名]` 占位符)。
### 发送草稿
```
@@ -533,13 +533,13 @@ Usage:
Example:
dws mail user search --keyword "张三"
dws mail user search --email user@company.com --keyword "张三"
dws mail user search --email user@company.com --keyword "alice" --size 10
dws mail user search --email user@company.com --keyword "alice" --limit 10
dws mail user search --email user@company.com --keyword "alice" --cursor <nextCursor>
Flags:
--email string 搜索目标邮箱地址 (可选)
--keyword string 搜索关键词 (必填)
--cursor string 分页游标,取自响应中的 nextCursor 字段(可选)
--size string 每页返回数量(可选)
--limit string 每页返回数量(可选)
```
> **重要区别:**
@@ -621,29 +621,29 @@ dws mail mailbox list --format json
# 2. 搜索邮件 — 提取 messageId
dws mail message search --email user@company.com \
--query "subject:\"周报\" AND date>2025-06-01T00:00:00Z" --size 10 --format json
--query "subject:\"周报\" AND date>2025-06-01T00:00:00Z" --limit 10 --format json
# 3. 查看邮件详情
dws mail message get --email user@company.com --id <messageId> --format json
# 4. 发送邮件(纯文本)
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "本周完成…" --format json
--subject "周报" --content "本周完成…" --format json
# 4b. 发送带附件的邮件(自动编排:创建草稿→上传附件→发送草稿)
dws mail message send --from user@company.com --to colleague@company.com \
--subject "周报" --body "见附件" --attachment ./report.pdf --format json
--subject "周报" --content "见附件" --attachment ./report.pdf --format json
# 4c. 发送带内联图片的邮件(正文自动转 HTML,<img> 标签自动注入)
dws mail message send --from user@company.com --to colleague@company.com \
--subject "图表周报" --body "本周图表如下:[inline:chart.png]" \
--subject "图表周报" --content "本周图表如下:[inline:chart.png]" \
--inline-attachment ./chart.png --format json
# 5. 下载邮件附件到本地(每次只能下载一个附件,不支持批量下载)
# 步骤 5.1:搜索匹配的邮件,获取 messageId 列表
# 示例:下载4月所有发票邮件的附件
dws mail message search --email user@company.com \
--query "subject:发票 AND date>2025-04-01T00:00:00Z AND date<2025-05-01T00:00:00Z AND hasAttachments:true" --size 50 --format json
--query "subject:发票 AND date>2025-04-01T00:00:00Z AND date<2025-05-01T00:00:00Z AND hasAttachments:true" --limit 50 --format json
# 步骤 5.2:对每封邮件,列出附件获取 attachmentId 和 name
# (对搜索结果中的每封邮件都要执行一次)
@@ -656,7 +656,7 @@ dws mail attachment download --email user@company.com \
# 5. 获取邮件所属会话详情(thread)
# 步骤 5.1:先通过 message search 或 message get 获取邮件中的 conversationId
dws mail message search --email user@company.com \
--query "subject:\"周报\"" --size 5 --format json
--query "subject:\"周报\"" --limit 5 --format json
# 从返回的邮件列表中提取 conversationId 字段
# 步骤 5.2:用 conversationId 获取会话详情
+28 -28
View File
@@ -22,9 +22,9 @@ minutes 模块的命令是**两级或三级结构**,不同层级之间不能
```
dws minutes
├── list <scope> # 查询听记列表(list 后必须跟 mine / shared / all)
│ ├── mine [--query] [--start] [--end] [--max] [--next-token] # 仅查询我自己创建/发起的听记(不含他人共享给我的)
│ ├── shared [--query] [--start] [--end] [--max] [--next-token] # 仅查询他人共享给我的听记(不含我自己创建的)
│ └── all [--query] [--start] [--end] [--max] [--next-token] # 查询我可访问的所有听记(= mine ∪ shared,覆盖面最广,默认 scope)
│ ├── mine [--query] [--start] [--end] [--max] [--cursor] # 仅查询我自己创建/发起的听记(不含他人共享给我的)
│ ├── shared [--query] [--start] [--end] [--max] [--cursor] # 仅查询他人共享给我的听记(不含我自己创建的)
│ └── all [--query] [--start] [--end] [--max] [--cursor] # 查询我可访问的所有听记(= mine ∪ shared,覆盖面最广,默认 scope)
├── get <subcommand> # 获取听记详情(get 后必须跟子命令名)
│ ├── info --id <uuid> # 获取听记基础信息(标题、时长、参与人、状态等)
│ ├── summary --id <uuid> # 获取听记 AI 摘要(结构化纪要)
@@ -102,15 +102,15 @@ dws minutes
>
> | 语义 | 规约 Primary | minutes 当前 CLI 实际参数 | alias 注册状态 | 说明 |
> |------|-------------|------------------------|--------------|------|
> | 单页大小 (Group 13) | `--limit` | `--max` | `--limit` 未注册 | minutes 用 `--max`,其他模块用 `--limit`。**跨模块切换时注意差异** |
> | 续页标识 (Group 14) | `--cursor` | `--next-token` | `--next-token` 在 alias 池中 | minutes 用 `--next-token`,合法 alias |
> | 单页大小 (Group 13) | `--limit` | `--limit`(别名 `--max`) | 已对齐 | `list mine/shared/all` 用 `--limit`,`--max` 为别名 |
> | 续页标识 (Group 14) | `--cursor` | `list`: `--cursor`;`get transcription`: `--next-token` | 部分对齐 | **list 系列用 `--cursor`,get transcription 用 `--next-token`,不要混用** |
> | 搜索关键词 (Group 11) | `--query` | `--query` | 已对齐 | 无差异 |
> | 起始时间 (Group 15) | `--start` | `--start` | 已对齐 | 无差异 |
> | 结束时间 (Group 16) | `--end` | `--end` | 已对齐 | 无差异 |
>
> **跨模块参数名差异速查(防止混用):**
> - 分页大小:minutes 用 `--max`,aitable/calendar/chat/drive 用 `--limit`
> - 续页标识:minutes 用 `--next-token`,aitable/drive/doc 用 `--cursor`
> - 分页大小:minutes `list` 用 `--limit`(别名 `--max`),与其他模块一致
> - 续页标识:minutes `list mine/shared/all` 用 `--cursor`;minutes `get transcription` 用 `--next-token`;aitable/drive/doc 用 `--cursor`
> - 起止时间:minutes/report 用 `--start`/`--end`,calendar 用 `--start-time`/`--end-time`(跨模块最高频错误)
**铁律:禁止编造 taskUuid(0512 P0 Golden Case 提炼)**
@@ -271,9 +271,9 @@ dws minutes
```
dws minutes
├── list <scope> # 二级:list 后必须跟 mine / shared / all
│ ├── mine [--query] [--start] [--end] [--max] [--next-token]
│ ├── shared [--query] [--start] [--end] [--max] [--next-token]
│ └── all [--query] [--start] [--end] [--max] [--next-token]
│ ├── mine [--query] [--start] [--end] [--max] [--cursor]
│ ├── shared [--query] [--start] [--end] [--max] [--cursor]
│ └── all [--query] [--start] [--end] [--max] [--cursor]
├── get <subcommand> # 二级:get 后必须跟子命令名
│ ├── info --id <uuid>
│ ├── summary --id <uuid>
@@ -320,17 +320,17 @@ Usage:
Example:
dws minutes list mine
dws minutes list mine --max 10
dws minutes list mine --max 10 --next-token <nextToken>
dws minutes list mine --max 10 --cursor <nextCursor>
dws minutes list mine --query "周会"
Flags:
--max float 查询的听记篇数 (默认 10)
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
--max float 查询的听记篇数 (默认 10,--limit 的别名)
--cursor string 分页游标 (首页留空,后续填写前次返回的 nextCursor)
--query string 关键字筛选 (可选)
--start string 开始时间 ISO-8601 (可选)
--end string 结束时间 ISO-8601 (可选)
```
查询我创建的听记列表,支持 `--max` 和 `--next-token` 分页,支持按关键字和时间范围筛选。
查询我创建的听记列表,支持 `--max` 和 `--cursor` 分页,支持按关键字和时间范围筛选。
### 查询他人共享给我的听记列表
```
@@ -339,16 +339,16 @@ Usage:
Example:
dws minutes list shared
dws minutes list shared --max 20
dws minutes list shared --max 5 --next-token <nextToken>
dws minutes list shared --max 5 --cursor <nextCursor>
Flags:
--max float 查询的听记篇数 (默认 10)
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
--max float 查询的听记篇数 (默认 10,--limit 的别名)
--cursor string 分页游标 (首页留空,后续填写前次返回的 nextCursor)
--query string 关键字筛选 (可选)
--start string 开始时间 ISO-8601 (可选)
--end string 结束时间 ISO-8601 (可选)
```
查询他人共享给我的听记列表,支持 `--max` 和 `--next-token` 分页,支持按关键字和时间范围筛选。
查询他人共享给我的听记列表,支持 `--max` 和 `--cursor` 分页,支持按关键字和时间范围筛选。
### 查询我有权限访问的所有听记列表
```
@@ -359,16 +359,16 @@ Example:
dws minutes list all --max 20
dws minutes list all --query "周会" --max 20
dws minutes list all --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T23:59:59+08:00"
dws minutes list all --max 10 --next-token <nextToken>
dws minutes list all --max 10 --cursor <nextCursor>
Flags:
--end string 结束时间 ISO-8601 (可选)
--query string 关键字筛选 (可选)
--max float 查询的听记篇数 (默认 10)
--next-token string 分页 token (首页留空,后续填写前次返回的 nextToken)
--max float 查询的听记篇数 (默认 10,--limit 的别名)
--cursor string 分页游标 (首页留空,后续填写前次返回的 nextCursor)
--start string 开始时间 ISO-8601 (可选)
```
查询我有权限访问的所有听记列表(包括我创建的、他人共享给我的等所有有权限的听记)。支持按关键字和时间范围筛选。时间范围和关键字为可选参数,不传则返回所有有权限的听记。支持使用 `--max` 和 `--next-token` 进行分页查询。
查询我有权限访问的所有听记列表(包括我创建的、他人共享给我的等所有有权限的听记)。支持按关键字和时间范围筛选。时间范围和关键字为可选参数,不传则返回所有有权限的听记。支持使用 `--max` 和 `--cursor` 进行分页查询。
### 获取听记基础信息
```
@@ -1607,7 +1607,7 @@ dws minutes record start --format json
# 1. 查看我的听记列表 — 提取 taskUuid
dws minutes list mine --format json
dws minutes list mine --max 10 --next-token <nextToken> --format json
dws minutes list mine --max 10 --cursor <nextCursor> --format json
dws minutes list mine --query "周会" --format json
# 1b. 查看共享给我的听记
@@ -1678,9 +1678,9 @@ dws minutes upload cancel --session-id <sessionId> --format json
| 操作 | 从返回中提取 | 用于 |
|------|-------------|------|
| `list mine` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `list shared` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `list all` | `taskUuid`、`nextToken` | get/update 的 --id;翻页时 --next-token |
| `list mine` | `taskUuid`、`nextCursor` | get/update 的 --id;翻页时 --cursor |
| `list shared` | `taskUuid`、`nextCursor` | get/update 的 --id;翻页时 --cursor |
| `list all` | `taskUuid`、`nextCursor` | get/update 的 --id;翻页时 --cursor |
| `get batch` | 各听记 `taskUuid` | 进一步查询详情 |
| `get audio` | 音频/视频 OSS 地址 | 用 HTTP GET 下载录音文件 / 在浏览器播放 |
| `record start` | `taskUuid`/`uuid` | record pause/resume/stop 的 --id |
@@ -1841,7 +1841,7 @@ loop:
- `record pause` / `record resume` / `record stop` 对应 `cmd=pause/resume/end`,需要传入 `--id`(映射 MCP 入参 `uuid`)
- 如果用户传入听记 URL(格式: `https://shanji.dingtalk.com/app/transcribes/<taskUuid>`),直接从路径末段提取 taskUuid 作为 `--id` 参数,无需再调用 list 查询
- `list mine`、`list shared`、`list all` 统一走 `list_by_keyword_and_time_range` 链路,通过 `belongingConditionId` 区分(`created` / `shared` / `noLimit`)
- 三个 list 命令均支持 `--max`、`--next-token` 分页及 `--query`、`--start`、`--end` 筛选
- 三个 list 命令均支持 `--max`(=--limit)、`--cursor` 分页及 `--query`、`--start`、`--end` 筛选
- `list mine`、`list shared` 默认每页 20 条,`list all` 默认每页 10 条
- `get summary` 返回 AI 生成的结构化 Markdown 摘要
- `get transcription` 的 `--direction` 控制时间排序: 0=正序(默认), 1=倒序;当用户明确要求查看/分析转写原文时,默认自动翻页拉取全部原文(不需要用户手动说"拉第一页"),如果用户意图不是专门看原文(如查列表、看摘要),则不应主动调用此命令
@@ -1860,7 +1860,7 @@ loop:
- 当用户提供关键词或时间范围时,必须使用 --query / --start / --end 在服务端筛选,严禁全量拉取后本地过滤。**严禁在 dws 命令后拼接 shell 管道做本地过滤**(如 `| grep "关键词"` / `| head -50` / `python -c "import json;..."` / `> /tmp/xxx.txt && grep ...`),这些写法在 Windows 沙箱里 100% 失败(`head`/`grep`/`wait` 不是 Windows 内置命令),即使在 macOS 上也会因 list 本身失败导致整个管道链路断掉。正确做法是始终使用 `--query` / `--start` / `--end` / `--max` 等 cli 内置参数在服务端完成筛选
- `dws minutes list` 后面**必须跟 `mine` / `shared` / `all` 子命令**,不能直接 `dws minutes list --start ... --end ...`(会报 `unknown flag: --start`)。正确写法:`dws minutes list mine --start ... --end ...` 或 `dws minutes list all --query "关键词" --start ... --end ...`
- get transcription 翻页时,同一个 next-token 连续返回空 2 次即终止翻页,不再重试
- **跨平台兼容性**:悟空运行环境可能是 Windows cmd / PowerShell / macOS bash,**严禁在 dws 命令中使用任何依赖特定 shell 的工具或管道**,包括但不限于:`| head`、`| grep`、`| tail`、`| wc`、`& wait`、`> %TEMP%\xxx`、`timeout /t 5`、`Start-Sleep`、`python -c "..."` 等。所有数据筛选、截断、过滤都必须通过 dws cli 自身的参数完成(`--query`/`--start`/`--end`/`--max`/`--next-token`)
- **跨平台兼容性**:悟空运行环境可能是 Windows cmd / PowerShell / macOS bash,**严禁在 dws 命令中使用任何依赖特定 shell 的工具或管道**,包括但不限于:`| head`、`| grep`、`| tail`、`| wc`、`& wait`、`> %TEMP%\xxx`、`timeout /t 5`、`Start-Sleep`、`python -c "..."` 等。所有数据筛选、截断、过滤都必须通过 dws cli 自身的参数完成(`--query`/`--start`/`--end`/`--max`/`--cursor`,get transcription 翻页用 `--next-token`)
### 错误恢复策略(AI 遇到以下错误时必须自动执行对应恢复动作)
+1 -1
View File
@@ -302,7 +302,7 @@ Flags:
- 用户说"复制知识库里的文档" → `node copy`(需 `--workspace` + `--node`)
- 用户说"移动知识库里的文档" → `node move`(需 `--workspace` + `--node`)
- 用户说"删除知识库里的文档/节点" → `node delete`(需 `--workspace` + `--node`)
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--user` + `--role`)
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--users` + `--role`)
- 用户说"修改某人在知识库的权限/调整成员角色" → `member update`
- 用户说"移除知识库成员/把某人从知识库移除/删除知识库成员" → `member remove`(需 `--workspace` + `--users`)
- 用户说"知识库有哪些成员/查看知识库成员" → `member list`
+7 -7
View File
@@ -35,17 +35,17 @@ metadata:
| 用户说 | 命令 |
|--------|------|
| "今天收到的日志" | `python scripts/report_received_today.py` |
| "看日志模版" | `dws report template list` → `dws report template detail --name "<模版名>"` |
| "提交日报 / 周报(按模版)" | `dws report create --template-id <id> --contents '[...]' |
| "我已发送的日志" | `dws report sent --start <ISO> --end <ISO>` |
| "日志已读统计" | `dws report stats --report-id <id>` |
| "看日志模版" | `dws report template list` → `dws report template get --name "<模版名>"` |
| "提交日报 / 周报(按模版)" | `dws report entry submit --template-id <id> --contents-file <tmp>.json` |
| "我已发送的日志" | `dws report outbox list --start <ISO> --end <ISO>` |
| "日志已读统计" | `dws report entry stats --report-id <id>` |
| "生成日报 / 周报 / 月报 / 主题报告" | 见 [05-reporting.md](references/05-reporting.md) recipe |
## 日志查询硬约束
- 查“收到的日志”必须用 `dws report list --start "<ISO>" --end "<ISO>" --cursor 0 --size 20 --format json`,并把“今天 / 最近 30 天”等时间词先展开成完整 ISO 起止时间。
- 列表返回后,后续 `detail` / `stats` 必须复用同一个 `reportId`;不要重新挑选、猜测或改用标题。
- 用户要正文时用 `dws report detail --report-id <reportId>`;用户要已读/统计时用 `dws report stats --report-id <reportId>`。
- 查“收到的日志”必须用 `dws report inbox list --start "<ISO>" --end "<ISO>" --cursor 0 --size 20 --format json`,并把“今天 / 最近 30 天”等时间词先展开成完整 ISO 起止时间。
- 列表返回后,后续 `entry get` / `entry stats` 必须复用同一个 `reportId`;不要重新挑选、猜测或改用标题。
- 用户要正文时用 `dws report entry get --report-id <reportId>`;用户要已读/统计时用 `dws report entry stats --report-id <reportId>`。
## 跨产品协作
@@ -8,7 +8,7 @@
| query 中是否含强信号 | 选哪个 recipe | 默认值 |
|---------------------|---------------|--------|
| 含「钉钉日志 / OA 周报 / 我的钉钉日志 / 日报模板 / 周报模板 / 提交日志 / 填模版」 | `submit-report`(走 dws report create) | — |
| 含「钉钉日志 / OA 周报 / 我的钉钉日志 / 日报模板 / 周报模板 / 提交日志 / 填模版」 | `submit-report`(走 dws report entry submit) | — |
| 含「在线文档 / 写一篇文档 / 整理成文档 / 文档保存」 | `generate-*-report`(走 dws doc create) | — |
| **无强信号**(如"写日报"、"写周报"、"整理本周工作")| `generate-*-report`(走 dws doc create) | 默认 |
@@ -21,9 +21,9 @@
| Recipe | 行动指南(固定路线) |
|--------|-------------------|
| query-report | **0. 前置判定**:query 含「查日志 / 看日志 / 我发过的日志 / 收到的日志 / 日志详情」且语义指向钉钉日志 OA 应用?是 → 直接走 `dws report`;否 → 先按 doc/report 分歧澄清<br>1. 用户说「我发过 / 我创建」→ `report sent --cursor 0 --size 20 --format json`;用户说「收到 / 别人发给我」→ `report list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json`(注意:`dws report inbox` 当前 CLI 未实现,禁止编造)<br>2. 时间 flag 只允许 `--start` / `--end`,禁止 `--start-date` / `--end-date` / `--date`;不要只传裸日期,必须展开完整 ISO;不要先查 `help`,不要预先登录;只有命令返回认证错误时才处理认证<br>3. 从列表返回中取 `reportId` 留给内部后续调用;如果用户已直接提供 `reportId`,跳过列表<br>4. 面向用户展示列表时必须基于 `result[]` 拼 Markdown 表:`日期 | 标题 | 发送人 | 状态 | 钉钉链接`;每条 `result[]` 都带这五个中文字段;不要把日志 ID 作为主列;已读状态字段缺失则不展示,不要编造<br>5. 用户要看正文时再执行 `report detail --report-id <reportId> --format json`;用户要统计 / 已读情况时执行 `report stats --report-id <reportId> --format json`<br>**不要把 list/sent 当正文接口**;查询正文必须补 `detail` |
| query-report | **0. 前置判定**:query 含「查日志 / 看日志 / 我发过的日志 / 收到的日志 / 日志详情」且语义指向钉钉日志 OA 应用?是 → 直接走 `dws report`;否 → 先按 doc/report 分歧澄清<br>1. 用户说「我发过 / 我创建」→ `report outbox list --cursor 0 --size 20 --format json`;用户说「收到 / 别人发给我」→ `report inbox list --start "<YYYY-MM-DDT00:00:00+08:00>" --end "<YYYY-MM-DDT23:59:59+08:00>" --cursor 0 --size 20 --format json`<br>2. 时间 flag 只允许 `--start` / `--end`,禁止 `--start-date` / `--end-date` / `--date`;不要只传裸日期,必须展开完整 ISO;不要先查 `help`,不要预先登录;只有命令返回认证错误时才处理认证<br>3. 从列表返回中取 `reportId` 留给内部后续调用;如果用户已直接提供 `reportId`,跳过列表<br>4. 面向用户展示列表时必须基于 `result[]` 拼 Markdown 表:`日期 | 标题 | 发送人 | 状态 | 钉钉链接`;每条 `result[]` 都带这五个中文字段;不要把日志 ID 作为主列;已读状态字段缺失则不展示,不要编造<br>5. 用户要看正文时再执行 `report entry get --report-id <reportId> --format json`;用户要统计 / 已读情况时执行 `report entry stats --report-id <reportId> --format json`<br>**不要把 inbox list/outbox list 当正文接口**;查询正文必须补 `entry get`<br>**不要再生成** `report list` / `report sent` / `report detail` / `report stats`(deprecated alias,仍能跑但会打 stderr 警告) |
| generate-daily-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=今日)<br>2. 交叉汇总并把日报内容写入临时文件 `<tmp>.md`(UTF-8,真实换行)<br>3. **创建文档**:`doc create --name "<日报名>" --content-file <tmp>.md`(> 200KB 按 `dingtalk-doc/references/04-document.md` 的 write-doc 兜底走 create 空 → 循环 update) |
| generate-weekly-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=本周)<br>2. 交叉对比并把周报内容写入临时文件 `<tmp>.md`<br>3. **创建文档**:`doc create --name "<周报名>" --content-file <tmp>.md`(兜底同上) |
| submit-report | **0. 前置判定**:query 含「钉钉日志 / OA 周报模板 / 我的钉钉日志」等强信号?是 → 继续;否 → 切换到 `generate-weekly-report` 或 `generate-daily-report`(走 dws doc)<br>1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=当日)<br>2. `report template list --format json` → 取 `report_template_id`<br>3. `report template detail --name "<模版名>" --format json` → 取 `result.report_template_fields[]`,每项含 `field_name`/`field_sort`/`field_type`<br>4. **把 contents 写入临时文件**(避免 shell 引号问题):每项含 `key`/`sort`/`content`/`contentType`/`type` 五个字段,**严格映射** `field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`<br>5. `report create --template-id <id> --contents '[...]' (或 echo "$json" | ... --contents -) --format json` → CLI 会在创建成功后自动反查详情并追加 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `dingtalkOpenLink` 字段;取返回的 `reportId` 与钉钉打开链接<br>6. final reply 优先直接使用 `dingtalkOpenMarkdownLink`,让用户点击跳转钉钉客户端查看 / 修改;仅当 create 返回中缺少 `dingtalkOpenUrl` 时,才手动执行 `report detail --report-id <reportId> --format json` 补取 `result.url`,再包装成 `[在钉钉中查看日志](result.url)`<br>**不要走 doc 写文档**;**禁止跳过 2/3 步**直接 create;**禁止把 raw `dingtalk://...` URL 直接粘到回复**,必须包成 markdown link |
| generate-monthly-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=当月)<br>2. `report sent --start "<月初ISO>" --end "<月末ISO>"` → 取当月已提交日志<br>3. 按周分段归纳并把月报内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<月报名>" --content-file <tmp>.md`(兜底同上) |
| submit-report | **0. 前置判定**:query 含「钉钉日志 / OA 周报模板 / 我的钉钉日志」等强信号?是 → 继续;否 → 切换到 `generate-weekly-report` 或 `generate-daily-report`(走 dws doc)<br>1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=当日)<br>2. `report template list --format json` → 取 `report_template_id`<br>3. `report template get --name "<模版名>" --format json` → 取 `result.report_template_fields[]`,每项含 `field_name`/`field_sort`/`field_type`<br>4. **把 contents 写入临时文件**(避免 shell 引号问题):每项含 `key`/`sort`/`content`/`contentType`/`type` 五个字段,**严格映射** `field_name → key`、`field_sort → sort`、`field_type → type`,再填 `content` 与 `contentType`<br>5. `report entry submit --template-id <id> --contents-file <tmp>.json --format json` → CLI 会在提交成功后自动反查详情并追加 `dingtalkOpenMarkdownLink` / `dingtalkOpenUrl` / `dingtalkOpenLink` 字段;取返回的 `reportId` 与钉钉打开链接<br>6. final reply 优先直接使用 `dingtalkOpenMarkdownLink`,让用户点击跳转钉钉客户端查看 / 修改;仅当 submit 返回中缺少 `dingtalkOpenUrl` 时,才手动执行 `report entry get --report-id <reportId> --format json` 补取 `result.url`,再包装成 `[在钉钉中查看日志](result.url)`<br>**不要走 doc 写文档**;**禁止跳过 2/3 步**直接 submit;**禁止把 raw `dingtalk://...` URL 直接粘到回复**,必须包成 markdown link<br>**不要再生成** `report template detail` / `report create`(deprecated alias,仍能跑但会打 stderr 警告) |
| generate-monthly-report | 1. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行(时间=当月)<br>2. `report outbox list --start "<月初ISO>" --end "<月末ISO>"` → 取当月已提交日志<br>3. 按周分段归纳并把月报内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<月报名>" --content-file <tmp>.md`(兜底同上) |
| generate-topic-report | 1. 提取主题关键词;推断时间范围("最近"默认近 30 天)<br>2. 按「多源并行采集」(见 `dws-shared/references/best_practices/_common/conventions.md`)执行<br>3. 按时间线排列,交叉归纳核心结论/决策/行动项/未解决问题/演进脉络,并把内容写入临时文件 `<tmp>.md`<br>4. **创建文档**:`doc create --name "<报告名>" --content-file <tmp>.md`(兜底同上) |