Compare commits

..

1 Commits

Author SHA1 Message Date
sunpeiyang.996
da237cc73a docs(skill): describe html5 block xml usage
Change-Id: I8bb82749c6eef9ef0470cb1d6e242cb5a0e763aa
2026-07-08 17:23:23 +08:00
57 changed files with 183 additions and 2440 deletions

View File

@@ -2,29 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.67] - 2026-07-08
### Features
- **mail**: add message modify and trash shortcuts (#1567)
- support whiteboard file inputs in docs XML (#1784)
- **vc**: refine meeting-events output and reaction forwarding (#1674)
- **affordance**: usage guidance for shortcuts and per-command skills (#1793)
### Bug Fixes
- accept opaque wiki node tokens (#1789)
- **apps**: make db --environment optional, auto-select branch server-side (#1735)
- preserve original filename in multipart file upload (#1767)
### Documentation
- restore one-time authorization guidance in lark-apps skill (#1794)
### Misc
- e2e: harden CLI E2E retry, cleanup, and domain selection (#1709)
## [v1.0.66] - 2026-07-07
### Features
@@ -1421,7 +1398,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64

View File

@@ -10,33 +10,18 @@ step. Maintain these files alongside `skills/` and `shortcuts/`.
A small, fixed markdown subset; each file describes one domain:
# <domain> optional `> skill: <name>` applies to every command below
## <command> the command as typed, minus `lark-cli <domain>`; a
+-prefixed heading (## +create) targets that shortcut
## <command> the command as typed, minus `lark-cli <domain>`
<lead paragraph> when to use this command
### Avoid when when not to use it / which command to use instead
### Prerequisites what you must have first (e.g. an id, and where it comes from)
### Tips gotchas and constraints
### Examples **description** lines, each followed by a fenced command
### Skills bullet skill names, or name/relpath references
(lark-contact/references/x.md), to read for usage;
merged with the domain `> skill:` default (deduped,
domain first)
### <other heading> a custom section; flows through verbatim
Reference another command with `[[command]]` — it renders as `command` in help.
Under `Avoid when` it means "use that one instead"; under `Prerequisites`
("… from [[command]]") it means "get the input there first".
Both service-API commands (`## messages get`) and `+`-prefixed shortcuts
(`## +create`) take entries. A `### Skills` entry is a skill name (validated
against `<name>/SKILL.md`) or a `name/relpath` reference into that skill
(validated against the path); help drops any that don't resolve, so a typo shows
nothing. Point a command at its own reference (e.g. `+search-user`
`lark-contact/references/lark-contact-search-user.md`) rather than re-listing the
domain skill, which the `> skill:` default already covers. When a shortcut also
sets a hand-authored `Tips` list in Go, the overlay's `### Tips` win — they
replace the Go tips (not merged), so keep tips in one place.
## Example
## messages get
@@ -62,5 +47,3 @@ replace the Go tips (not merged), so keep tips in one place.
anything the schema and flags already show; the agent infers the rest.
- Command-form headings resolve to method ids via the registry, so plural resource
names (`messages`) map to the singular method id (`message`) automatically.
`+`-prefixed shortcut headings are matched verbatim (no plural/space folding),
so the heading must equal the shortcut command exactly (`## +history-revert`).

View File

@@ -1,42 +1,6 @@
# contact
> skill: lark-contact
## +search-user
The primary user lookup for user identity: search by keyword or email, resolve known ids with --user-ids, or get yourself with --user-ids me — it does by-id reads too, so as a user you rarely need `+get-user`. Each match returns an open_id and p2p_chat_id to chain into follow-ups.
### Skills
- lark-contact/references/lark-contact-search-user.md
### Avoid when
- Running as a bot — this shortcut is user-only; use [[+get-user]] instead (it supports bot identity)
- You only need users' personal status for ids you already hold → use [[user_profiles batch_query]]
### Examples
**Find a user by name**
```bash
lark-cli contact +search-user --query "alice" --as user
```
**Fetch known users by open_id (me = yourself)**
```bash
lark-cli contact +search-user --user-ids "ou_3a8b****6a7b,me" --as user
```
## +get-user
Fetch one user's profile by id, or your own with --user-id omitted. Use it under bot identity — `+search-user` is user-only.
### Skills
- lark-contact/references/lark-contact-get-user.md
### Avoid when
- You don't have the user's id yet, or want to match by name/keyword → use [[+search-user]]
- Running as a user — [[+search-user]] --user-ids covers by-id reads and more in one tool
### Tips
- Self lookup (omit --user-id) needs user identity; a bot must pass --user-id
- --user-id-type must match the id you pass (default open_id)
## user_profiles batch_query
Bulk-fetch personal status and signature for user ids you already have.

View File

@@ -4,14 +4,10 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"sort"
"strings"
"testing"
@@ -1073,157 +1069,3 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
t.Errorf("expected method GET, got %s", gotOpts.Method)
}
}
// parseMultipartFilenames drives one api --file upload through the mock
// transport and returns a map of field name -> part filename parsed from the
// captured multipart body, plus the map of text form fields. It fails the test
// if the captured request is not multipart/form-data.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) (map[string]string, map[string]string) {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
fields := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
} else {
buf := &bytes.Buffer{}
_, _ = buf.ReadFrom(part)
fields[part.FormName()] = buf.String()
}
}
return filenames, fields
}
func TestApiCmd_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q", "file", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_FieldPrefixKeepsBasename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.MkdirAll(filepath.Join(dir, "sub"), 0700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "sub", "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "upload=sub/invoice.pdf"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if _, ok := filenames["upload"]; !ok {
t.Fatalf("expected field name %q from field=path form, got fields %v", "upload", filenames)
}
if got := filenames["upload"]; got != "invoice.pdf" {
t.Fatalf("part filename for field %q = %q, want %q (basename only)", "upload", got, "invoice.pdf")
}
}
func TestApiCmd_FileUpload_WithDataFields(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "invoice.pdf"), []byte("%PDF-1.4 fake"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot",
"--file", "invoice.pdf", "--data", `{"type":"attachment"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, fields := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "invoice.pdf" {
t.Fatalf("part filename = %q, want %q", got, "invoice.pdf")
}
if got := fields["type"]; got != "attachment" {
t.Fatalf("text field type = %q, want %q", got, "attachment")
}
}
func TestApiCmd_FileUpload_StdinFallsBackToUnknown(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
f.IOStreams.In = bytes.NewReader([]byte("stdin-bytes"))
stub := &httpmock.Stub{
URL: "/open-apis/approval/v4/files/upload",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"code": "file_xxx"}},
}
reg.Register(stub)
cmd := NewCmdApi(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/approval/v4/files/upload", "--as", "bot", "--file", "-"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames, _ := parseMultipartFilenames(t, stub)
if got := filenames["file"]; got != "unknown-file" {
t.Fatalf("stdin part filename = %q, want %q (no stable local name, fallback)", got, "unknown-file")
}
}

View File

@@ -679,11 +679,7 @@ func installTipsHelpFunc(root *cobra.Command) {
defaultHelp(cmd, args)
return
}
if service.PrepareMethodHelp(cmd, embeddedSkillContent) {
defaultHelp(cmd, args)
return
}
if service.PrepareShortcutHelp(cmd, embeddedSkillContent) {
if service.PrepareMethodHelp(cmd) {
defaultHelp(cmd, args)
return
}

View File

@@ -71,18 +71,11 @@ func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool {
}
// domainHelpBase returns the description to seed domain help with — the
// hand-authored Long when present, else the Short.
// hand-authored Long when present, else the Short — captured once into an
// annotation so re-rendering reuses the pristine text instead of the
// already-augmented Long.
func domainHelpBase(cmd *cobra.Command) string {
return captureHelpBase(cmd, domainBaseAnnotation)
}
// captureHelpBase records a command's pristine lead text once — its
// hand-authored Long, or Short when Long is empty — into the given annotation,
// so lazy re-renders compose onto the original text instead of onto an
// already-augmented Long. This is what lets a shortcut's PostMount-authored
// Long survive: it becomes the base the affordance block is appended below.
func captureHelpBase(cmd *cobra.Command, key string) string {
if base, ok := cmd.Annotations[key]; ok {
if base, ok := cmd.Annotations[domainBaseAnnotation]; ok {
return base
}
base := cmd.Long
@@ -92,7 +85,7 @@ func captureHelpBase(cmd *cobra.Command, key string) string {
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[key] = base
cmd.Annotations[domainBaseAnnotation] = base
return base
}
@@ -108,12 +101,12 @@ func methodLong(description, schemaPath, paramsOnly string) string {
}
// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long.
// The affordance overlay coordinates live in cmdmeta (shared with shortcuts).
const (
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
shortcutBaseAnnotation = "affordance-shortcut-base"
affordanceServiceAnnotation = "affordance-service"
affordanceMethodAnnotation = "affordance-method"
schemaPathAnnotation = "method-schema-path"
paramsOnlyAnnotation = "method-params-only"
domainBaseAnnotation = "affordance-domain-base"
)
// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a
@@ -122,7 +115,10 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmdmeta.SetAffordanceRef(cmd, service, methodID)
if service != "" && methodID != "" {
cmd.Annotations[affordanceServiceAnnotation] = service
cmd.Annotations[affordanceMethodAnnotation] = methodID
}
cmd.Annotations[schemaPathAnnotation] = schemaPath
if paramsOnly != "" {
cmd.Annotations[paramsOnlyAnnotation] = paramsOnly
@@ -132,11 +128,8 @@ func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, params
// PrepareMethodHelp rebuilds a generated method command's Long with the agent
// guidance at the TOP (Risk, then the affordance block, then the schema
// pointer), returning false for non-method commands. The overlay is parsed
// here — only when help is rendered. skillFS (nil-safe) gates the related-skill
// pointers: each is emitted only when it resolves in the skill tree (see
// affordance.SkillStatPath), so a typo or a build without embedded skills never
// prints a `skills read` that cannot be opened.
func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
// here — only when help is rendered.
func PrepareMethodHelp(cmd *cobra.Command) bool {
ann := cmd.Annotations
if ann == nil {
return false
@@ -148,15 +141,22 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
var b strings.Builder
b.WriteString(cmd.Short)
writeRisk(&b, cmd)
if level, ok := cmdutil.GetRisk(cmd); ok {
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(&b, "\n\nRisk: %s", level)
}
}
var skills []string
if raw, ok := affordanceRaw(cmd); ok {
if block := renderAffordance(meta.Method{Affordance: raw}); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok {
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
skills = a.Skills
}
}
@@ -164,93 +164,15 @@ func PrepareMethodHelp(cmd *cobra.Command, skillFS fs.FS) bool {
fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath)
b.WriteString(ann[paramsOnlyAnnotation])
writeRelatedSkills(&b, skills, skillFS)
cmd.Long = b.String()
return true
}
// PrepareShortcutHelp composes a +-prefixed shortcut's Long from its affordance
// overlay — the same top layout as method help (description, Risk, guidance
// block, related skills) minus the schema pointer, which shortcuts have none
// of. Returns false when the command is not a shortcut or carries no overlay
// entry, so shortcuts without guidance keep the default help plus the bottom
// risk/tips append.
//
// The lead is the command's pristine base (captureHelpBase): a shortcut that
// set a hand-authored Long in PostMount (e.g. the docs shortcuts' "agents MUST
// read the skill" directive) keeps it — the affordance block is appended below,
// never clobbering it.
//
// Tips precedence (intentional, not a bug): the overlay's ### Tips win. The
// shortcut's declarative Tips (the Go Tips field) are only a fallback used when
// the overlay declares none; when the overlay has tips, the Go tips are dropped
// (replaced, not merged) so tips never render twice. Authoring a ### Tips block
// therefore silently retires that shortcut's Go Tips — consolidate into one.
func PrepareShortcutHelp(cmd *cobra.Command, skillFS fs.FS) bool {
if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceShortcut {
return false
}
raw, ok := affordanceRaw(cmd)
if !ok {
return false
}
a, ok := (meta.Method{Affordance: raw}).ParsedAffordance()
if !ok {
return false
}
if len(a.Tips) == 0 {
a.Tips = cmdutil.GetTips(cmd)
}
var b strings.Builder
b.WriteString(captureHelpBase(cmd, shortcutBaseAnnotation))
writeRisk(&b, cmd)
if block := renderAffordanceValue(a); block != "" {
b.WriteString("\n\n")
b.WriteString(block)
}
writeRelatedSkills(&b, a.Skills, skillFS)
cmd.Long = b.String()
return true
}
// writeRisk appends the "Risk: <level>" line, warning agents not to self-approve
// high-risk-write commands. A no-op when the command has no risk annotation.
func writeRisk(b *strings.Builder, cmd *cobra.Command) {
level, ok := cmdutil.GetRisk(cmd)
if !ok {
return
}
// --yes asserts the USER confirmed; the agent must not self-approve.
if level == cmdutil.RiskHighRiskWrite {
fmt.Fprintf(b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level)
} else {
fmt.Fprintf(b, "\n\nRisk: %s", level)
}
}
// writeRelatedSkills appends the "Related skills" block for the entries that
// exist in skillFS. Nothing is written when skillFS is nil or no entry resolves,
// so help never prints a `skills read` pointer that cannot be opened.
func writeRelatedSkills(b *strings.Builder, skills []string, skillFS fs.FS) {
if skillFS == nil || len(skills) == 0 {
return
}
var avail []string
for _, s := range skills {
if _, err := fs.Stat(skillFS, affordance.SkillStatPath(s)); err == nil {
avail = append(avail, s)
if len(skills) > 0 {
b.WriteString("\n\nWorkflow skill (end-to-end usage):")
for _, s := range skills {
fmt.Fprintf(&b, "\n lark-cli skills read %s", s)
}
}
if len(avail) == 0 {
return
}
b.WriteString("\n\nRelated skills (read for end-to-end usage):")
for _, s := range avail {
fmt.Fprintf(b, "\n lark-cli skills read %s", s)
}
cmd.Long = b.String()
return true
}
// affordanceLookup is the overlay source; a package var so tests can inject.
@@ -267,8 +189,12 @@ func RenderAffordanceForCmd(cmd *cobra.Command) string {
}
func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) {
service, methodID, ok := cmdmeta.AffordanceRef(cmd)
if !ok {
if cmd.Annotations == nil {
return nil, false
}
service := cmd.Annotations[affordanceServiceAnnotation]
methodID := cmd.Annotations[affordanceMethodAnnotation]
if service == "" || methodID == "" {
return nil, false
}
return affordanceLookup(service, methodID)
@@ -281,13 +207,7 @@ func renderAffordance(m meta.Method) string {
if !ok {
return ""
}
return renderAffordanceValue(a)
}
// renderAffordanceValue renders an already-parsed affordance. Split from
// renderAffordance so callers can render a value they have adjusted first (e.g.
// a shortcut folding its declarative tips into an overlay that has none).
func renderAffordanceValue(a meta.Affordance) string {
var sections []string
bullets := func(title string, items []string) {
var nonEmpty []string

View File

@@ -7,7 +7,6 @@ import (
"encoding/json"
"strings"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/cmdmeta"
"github.com/larksuite/cli/internal/cmdutil"
@@ -71,8 +70,8 @@ func TestServiceMethod_AffordanceNotInLong(t *testing.T) {
t.Errorf("affordance must not be baked into Long (lazy):\n%s", cmd.Long)
}
// The lookup ref is recorded so the help path can resolve it later.
if svc, method, ok := cmdmeta.AffordanceRef(cmd); !ok || svc != "im" || method != "messages.create" {
t.Errorf("affordance ref = %q/%q (ok=%v), want im/messages.create", svc, method, ok)
if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" {
t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations)
}
}
@@ -120,7 +119,7 @@ func TestPrepareMethodHelp(t *testing.T) {
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd, nil) {
if !PrepareMethodHelp(cmd) {
t.Fatal("PrepareMethodHelp returned false for a service-method command")
}
long := cmd.Long
@@ -137,133 +136,11 @@ func TestPrepareMethodHelp(t *testing.T) {
}
// A non-service command (no schema-path annotation) is left untouched.
if PrepareMethodHelp(&cobra.Command{Use: "plain"}, nil) {
if PrepareMethodHelp(&cobra.Command{Use: "plain"}) {
t.Error("PrepareMethodHelp should return false for a non-service command")
}
}
// PrepareShortcutHelp composes a shortcut's Long from its overlay with the same
// top layout as method help (no schema pointer), folding declarative tips when
// the overlay declares none, and leaves shortcuts without an overlay entry (and
// non-shortcut commands) for the default help path.
func TestPrepareShortcutHelp(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(service, methodID string) (json.RawMessage, bool) {
if service == "calendar" && methodID == "+create" {
return json.RawMessage(`{"use_when":["高层创建日程"],"skills":["lark-calendar"]}`), true
}
return nil, false
}
sc := &cobra.Command{Use: "+create", Short: "Create an event"}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
cmdutil.SetRisk(sc, "write")
cmdutil.SetTips(sc, []string{"start/end 收 ISO 8601"})
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
for _, want := range []string{"Create an event", "Risk: write", "When to use:", "高层创建日程", "Tips:", "start/end 收 ISO 8601"} {
if !strings.Contains(sc.Long, want) {
t.Errorf("shortcut Long missing %q:\n%s", want, sc.Long)
}
}
if strings.Contains(sc.Long, "Full parameter schema:") {
t.Errorf("shortcut Long must not carry a schema pointer:\n%s", sc.Long)
}
// No overlay entry -> leave it for the default help path.
bare := &cobra.Command{Use: "+bare", Short: "x"}
cmdmeta.SetSource(bare, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(bare, "calendar", "+bare")
if PrepareShortcutHelp(bare, nil) {
t.Error("PrepareShortcutHelp should return false when the shortcut has no overlay")
}
// Non-shortcut source is ignored even with a ref.
notSc := &cobra.Command{Use: "create", Short: "x"}
cmdmeta.SetAffordanceRef(notSc, "calendar", "+create")
if PrepareShortcutHelp(notSc, nil) {
t.Error("PrepareShortcutHelp should return false for a non-shortcut command")
}
}
// Related-skill pointers are gated on existence: a skill that resolves in the
// skill FS renders, a typo is dropped (never print an unopenable `skills read`),
// and a nil skill FS suppresses the whole block.
func TestRelatedSkillsStatGating(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["x"],"skills":["lark-real","lark-typo","lark-real/references/deep.md","lark-real/references/missing.md"]}`), true
}
skillFS := fstest.MapFS{
"lark-real/SKILL.md": {Data: []byte("# real")},
"lark-real/references/deep.md": {Data: []byte("# deep")},
}
f, _, _, _ := cmdutil.TestFactory(t, testConfig)
m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "d"}
cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
if !PrepareMethodHelp(cmd, skillFS) {
t.Fatal("PrepareMethodHelp returned false")
}
if !strings.Contains(cmd.Long, "skills read lark-real\n") {
t.Errorf("existing bare-name skill should render on its own line; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "lark-typo") {
t.Errorf("nonexistent skill must be dropped, not printed as an unopenable pointer; got:\n%s", cmd.Long)
}
// A name/relpath reference to an existing file renders; a missing one drops.
if !strings.Contains(cmd.Long, "skills read lark-real/references/deep.md") {
t.Errorf("existing reference entry should render; got:\n%s", cmd.Long)
}
if strings.Contains(cmd.Long, "references/missing.md") {
t.Errorf("nonexistent reference must be dropped; got:\n%s", cmd.Long)
}
// nil skill FS: the whole Related-skills block is suppressed.
bare := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil)
PrepareMethodHelp(bare, nil)
if strings.Contains(bare.Long, "Related skills") {
t.Errorf("nil skillFS should suppress the skills block; got:\n%s", bare.Long)
}
}
// A shortcut that set a hand-authored Long (as the docs shortcuts do in
// PostMount) keeps it as the lead: the affordance block is appended below, not
// clobbered, and re-rendering does not double-append.
func TestPrepareShortcutHelp_PreservesPostMountLong(t *testing.T) {
orig := affordanceLookup
t.Cleanup(func() { affordanceLookup = orig })
affordanceLookup = func(_, _ string) (json.RawMessage, bool) {
return json.RawMessage(`{"use_when":["高层创建日程"]}`), true
}
const authored = "Custom docs help. AI agents MUST read the skill first."
sc := &cobra.Command{Use: "+create", Short: "Create", Long: authored}
cmdmeta.SetSource(sc, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(sc, "calendar", "+create")
if !PrepareShortcutHelp(sc, nil) {
t.Fatal("PrepareShortcutHelp returned false for a shortcut with an overlay")
}
if !strings.HasPrefix(sc.Long, authored) {
t.Errorf("hand-authored Long must lead, not be clobbered; got:\n%s", sc.Long)
}
if !strings.Contains(sc.Long, "When to use:") {
t.Errorf("affordance block should be appended below the base; got:\n%s", sc.Long)
}
// Re-render must reuse the captured base, not append the block twice.
PrepareShortcutHelp(sc, nil)
if n := strings.Count(sc.Long, "When to use:"); n != 1 {
t.Errorf("affordance appended %d times across re-renders, want 1:\n%s", n, sc.Long)
}
}
// domainCmd wires a domain-tagged command with a subcommand under a root, the
// shape PrepareDomainHelp expects.
func domainCmd(short, long string) *cobra.Command {

View File

@@ -4,14 +4,10 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"mime"
"mime/multipart"
"os"
"path/filepath"
"strings"
"testing"
@@ -1136,63 +1132,6 @@ func TestDetectFileFields(t *testing.T) {
}
}
// parseMultipartFilenames drives one service-method --file upload through the
// mock transport and returns a map of field name -> part filename parsed from
// the captured multipart body. Mirrors cmd/api's helper of the same name
// (inlined here rather than shared, since the two live in different packages)
// to give BuildFormdata's shared local-file fix a second real entry-point
// covering it.
func parseMultipartFilenames(t *testing.T, stub *httpmock.Stub) map[string]string {
t.Helper()
ct := stub.CapturedHeaders.Get("Content-Type")
mediaType, params, err := mime.ParseMediaType(ct)
if err != nil {
t.Fatalf("parse Content-Type %q: %v", ct, err)
}
if !strings.HasPrefix(mediaType, "multipart/") {
t.Fatalf("Content-Type = %q, want multipart/*", mediaType)
}
filenames := map[string]string{}
mr := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"])
for {
part, err := mr.NextPart()
if err != nil {
break
}
if fn := part.FileName(); fn != "" {
filenames[part.FormName()] = fn
}
}
return filenames
}
func TestServiceMethod_FileUpload_PreservesFilename(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, testConfig)
dir := t.TempDir()
cmdutil.TestChdir(t, dir)
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("fake-image"), 0600); err != nil {
t.Fatalf("write test file: %v", err)
}
stub := &httpmock.Stub{
URL: "/open-apis/im/v1/images",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"image_key": "img_xxx"}},
}
reg.Register(stub)
cmd := NewCmdServiceMethod(f, imSpec(), imImageMethod(), "create", "images", nil)
cmd.SetArgs([]string{"--file", "photo.jpg", "--data", `{"image_type":"message"}`, "--as", "bot"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
filenames := parseMultipartFilenames(t, stub)
if got := filenames["image"]; got != "photo.jpg" {
t.Fatalf("part filename for field %q = %q, want %q", "image", got, "photo.jpg")
}
}
func TestServiceMethod_JsonFlag_Accepted(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, testConfig)

2
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.7.2
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/sergi/go-diff v1.4.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1

4
go.sum
View File

@@ -79,8 +79,8 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2 h1:SCIcXHRmtpQbiaZgDTDi1NYNCzrusi7ePJBR9uKoduE=
github.com/larksuite/oapi-sdk-go/v3 v3.7.2/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@@ -83,9 +83,10 @@ func commandFormResolver(service string) func(string) string {
}
}
return func(h string) string {
if id, ok := byForm[strings.TrimSpace(h)]; ok {
h = strings.TrimSpace(h)
if id, ok := byForm[h]; ok {
return id
}
return headingToKey(h) // one home for the shortcut/method key convention
return strings.ReplaceAll(h, " ", ".")
}
}

View File

@@ -7,8 +7,6 @@ import (
"encoding/json"
"testing"
"testing/fstest"
"github.com/larksuite/cli/internal/meta"
)
// fixtureMD is a minimal affordance source: two methods, each with a lead
@@ -86,38 +84,3 @@ func TestParseDomainMD_ParagraphNotDropped(t *testing.T) {
t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions)
}
}
// The ### Skills section merges with the domain `> skill:` default: domain
// first, then per-command entries, de-duplicated. A command with no ### Skills
// still inherits the domain default.
func TestParseDomainMD_SkillsMerge(t *testing.T) {
md := "# d\n> skill: lark-d\n\n" +
"## foo\ndoes foo.\n\n### Skills\n- lark-workflow\n- lark-d\n\n" + // lark-d duplicates the domain default
"## bar\ndoes bar.\n"
got := parseDomainMD([]byte(md), nil)
if a := got["foo"]; len(a.Skills) != 2 || a.Skills[0] != "lark-d" || a.Skills[1] != "lark-workflow" {
t.Errorf("foo skills = %v, want [lark-d lark-workflow] (domain first, deduped)", a.Skills)
}
if a := got["bar"]; len(a.Skills) != 1 || a.Skills[0] != "lark-d" {
t.Errorf("bar skills = %v, want [lark-d] (domain default inherited)", a.Skills)
}
}
// A +-prefixed shortcut heading keys verbatim (no space->dot folding), so it
// matches the shortcut command as mounted.
func TestParseDomainMD_ShortcutHeadingVerbatim(t *testing.T) {
md := "# d\n\n## +create\ncreate via shortcut.\n"
got := parseDomainMD([]byte(md), nil)
if _, ok := got["+create"]; !ok {
t.Errorf("shortcut heading should key as %q; got keys %v", "+create", keysOf(got))
}
}
func keysOf(m map[string]meta.Affordance) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View File

@@ -19,7 +19,6 @@ import (
// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge)
// ### Tips -> tips
// ### Examples -> examples: **description** + a ```fenced``` command
// ### Skills -> skills: bullet skill names, added to the domain default
// ### <other> -> extensions[] (custom section, flows through verbatim)
// [[cmd]] -> a command reference, rendered as `cmd`
//
@@ -35,56 +34,16 @@ var standardSection = map[string]string{
"Prerequisites": "prerequisites",
"Tips": "tips",
"Examples": "examples",
"Skills": "skills",
}
// mergeSkills returns the domain-default skill followed by a command's own skill
// entries, de-duplicated in author order and empties dropped. Backticks (left by
// the shared bullet parse) are stripped so each entry is a bare skill name.
func mergeSkills(domain string, extra []string) []string {
var out []string
seen := map[string]bool{}
add := func(s string) {
s = strings.Trim(strings.TrimSpace(s), "`")
if s == "" || seen[s] {
return
}
seen[s] = true
out = append(out, s)
}
add(domain)
for _, s := range extra {
add(s)
}
return out
}
func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") }
// SkillStatPath maps a `### Skills` entry to the path (relative to the skill
// tree) whose existence gates it: a bare skill name resolves to its SKILL.md,
// while an entry containing a slash is a name/relative-path reference (e.g.
// "lark-contact/references/lark-contact-search-user.md") and resolves to that
// path directly. Both render as `lark-cli skills read <entry>` — the slash form
// skills read already accepts — so a per-command entry can point at that
// command's own reference file, not just re-point the domain skill.
func SkillStatPath(entry string) string {
if strings.Contains(entry, "/") {
return entry
}
return entry + "/SKILL.md"
}
// headingToKey maps a command heading ("instances get") to its affordance key
// ("instances.get"). The space→dot rule holds where the command form matches
// the method id; domains whose resource names differ (e.g. plural "messages"
// vs id segment "message") need the registry's authoritative resource↔id table.
func headingToKey(h string) string {
h = strings.TrimSpace(h)
if strings.HasPrefix(h, "+") { // shortcut command: key is the command verbatim
return h
}
return strings.ReplaceAll(h, " ", ".")
return strings.ReplaceAll(strings.TrimSpace(h), " ", ".")
}
type mdSection struct {
@@ -123,7 +82,6 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
if len(useWhen) > 0 {
a.UseWhen = useWhen
}
var perCmdSkills []string
for _, s := range secs {
switch standardSection[s.label] {
case "avoid_when":
@@ -134,14 +92,12 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
a.Tips = s.items
case "examples":
a.Examples = s.cases
case "skills":
perCmdSkills = s.items
default:
a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items})
}
}
if s := mergeSkills(skill, perCmdSkills); len(s) > 0 {
a.Skills = s
if skill != "" {
a.Skills = []string{skill}
}
out[curKey] = a
}
@@ -201,7 +157,7 @@ func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affo
inFence, fence = true, nil
} else {
inFence = false
sec.cases = append(sec.cases, meta.AffordanceCase{Description: linkToBacktick(pending), Command: strings.Join(fence, "\n")})
sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")})
pending = ""
}
continue

View File

@@ -2,11 +2,9 @@
// SPDX-License-Identifier: MIT
// Package cmdmeta is the single source of truth for command metadata that the
// policy engine, the hook selector, and help rendering consume. It wraps the
// existing cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need, plus the
// affordance ref (service, method id) that lets service-method and shortcut
// help share one usage-guidance lookup path.
// policy engine and the hook selector both consume. It wraps the existing
// cmdutil annotations (risk_level, supportedIdentities) and adds the
// "domain" axis that the hook selector and Rule path globs need.
//
// Three axes:
//
@@ -53,12 +51,6 @@ const (
sourceAnnotationKey = "cmdmeta.source"
generatedAnnotationKey = "cmdmeta.generated"
// affordance{Service,Method}Key locate the command's usage-guidance overlay
// entry (see internal/affordance). Both service-method commands and
// +-prefixed shortcuts set these so help rendering shares one lookup path.
affordanceServiceKey = "cmdmeta.affordance.service"
affordanceMethodKey = "cmdmeta.affordance.method"
)
// Meta groups the three command-level metadata axes consumed by the policy
@@ -133,35 +125,6 @@ func SetSource(cmd *cobra.Command, source Source, generated bool) {
}
}
// SetAffordanceRef records which affordance overlay entry (service, method id)
// a command maps to, so help rendering can look up its usage guidance. Stored
// on the command itself (no inheritance): each method / shortcut owns its ref.
// A no-op if either coordinate is empty.
func SetAffordanceRef(cmd *cobra.Command, service, method string) {
if service == "" || method == "" {
return
}
if cmd.Annotations == nil {
cmd.Annotations = map[string]string{}
}
cmd.Annotations[affordanceServiceKey] = service
cmd.Annotations[affordanceMethodKey] = method
}
// AffordanceRef returns the command's own affordance overlay coordinates.
// ok is false when the command carries no ref.
func AffordanceRef(cmd *cobra.Command) (service, method string, ok bool) {
if cmd.Annotations == nil {
return "", "", false
}
service = cmd.Annotations[affordanceServiceKey]
method = cmd.Annotations[affordanceMethodKey]
if service == "" || method == "" {
return "", "", false
}
return service, method, true
}
// Domain returns the nearest-ancestor domain for the command. Empty string
// when no ancestor has the annotation -- this is the "unknown" state the
// policy engine must treat as ALLOW.

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"fmt"
"io"
"path/filepath"
"strconv"
"strings"
@@ -129,7 +128,7 @@ func BuildFormdata(fileIO fileio.FileIO, fieldName, filePath string, isStdin boo
WithParam("--file").
WithCause(err)
}
fd.AddFileWithName(fieldName, filepath.Base(filePath), bytes.NewReader(data))
fd.AddFile(fieldName, bytes.NewReader(data))
}
// Add top-level JSON keys as text form fields.

View File

@@ -113,7 +113,6 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
{"1062507 drive_parent_sibling_limit", 1062507, errs.CategoryAPI, errs.SubtypeQuotaExceeded, 1, "APIError"},
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},

View File

@@ -17,7 +17,6 @@ var driveCodeMeta = map[int]CodeMeta{
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
1062507: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // parent folder child count limit exceeded
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied

View File

@@ -8,11 +8,8 @@ import "encoding/json"
// Affordance is the typed usage guidance overlaid on a method. It is the single
// model the envelope renderer and the command help both parse, so the
// vocabulary is defined once; the JSON tags double as the envelope wire shape.
// Skills entries are either a bare skill name (e.g. "lark-doc") or a
// name/relative-path reference (e.g. "lark-contact/references/x.md"); both
// render as runnable `lark-cli skills read <entry>` pointers. Help validates
// each against the embedded skill tree (a name → its SKILL.md, a reference →
// that path) and drops any that do not resolve.
// Skills entries are skill names (or name/path) rendered as runnable
// `lark-cli skills read <entry>` pointers.
type Affordance struct {
UseWhen []string `json:"use_when,omitempty"`
AvoidWhen []string `json:"avoid_when,omitempty"`

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.67",
"version": "1.0.66",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -1,73 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/shortcuts/common"
)
func mountBaseShortcutFlags(t *testing.T, s common.Shortcut, name string) *cobra.Command {
t.Helper()
parent := &cobra.Command{Use: "test"}
s.Mount(parent, &cmdutil.Factory{})
cmd, _, err := parent.Find([]string{name})
if err != nil {
t.Fatalf("Find(%s) error = %v", name, err)
}
return cmd
}
// record-list 获得 --json 简写
func TestRecordListRegistersJSONShorthand(t *testing.T) {
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("+record-list missing --json shorthand")
}
if fl.Usage != "shorthand for --format json" {
t.Errorf("usage = %q, want shorthand", fl.Usage)
}
if def := cmd.Flags().Lookup("format").DefValue; def != "markdown" {
t.Errorf("format default = %q, want markdown (unchanged)", def)
}
}
// record-search / record-get 的 --json 保持请求体语义,不被覆盖(回归锚点)
func TestRecordSearchGetKeepRequestBodyJSON(t *testing.T) {
for _, tc := range []struct {
name string
shortcut common.Shortcut
cmdName string
}{
{"record-search", BaseRecordSearch, "+record-search"},
{"record-get", BaseRecordGet, "+record-get"},
} {
cmd := mountBaseShortcutFlags(t, tc.shortcut, tc.cmdName)
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatalf("%s: --json (request body) missing", tc.name)
}
if strings.Contains(fl.Usage, "shorthand") {
t.Fatalf("%s: request-body --json overwritten by shorthand: %q", tc.name, fl.Usage)
}
if fl.Value.Type() != "string" {
t.Fatalf("%s: --json type = %q, want string", tc.name, fl.Value.Type())
}
}
}
// Enum 已接入help 描述携带枚举后缀(框架对带 Enum 的 flag 自动追加 " (markdown|json)"
func TestRecordReadFormatFlagCarriesEnum(t *testing.T) {
cmd := mountBaseShortcutFlags(t, BaseRecordList, "+record-list")
usage := cmd.Flags().Lookup("format").Usage
if !strings.Contains(usage, "(markdown|json)") {
t.Fatalf("format usage missing enum suffix: %q", usage)
}
}

View File

@@ -85,7 +85,6 @@ func recordReadFormatFlag() common.Flag {
return common.Flag{
Name: "format",
Default: "markdown",
Enum: []string{"markdown", "json"},
Desc: "output format: markdown (default) | json",
}
}

View File

@@ -889,7 +889,6 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
}
}
cmdmeta.SetSource(cmd, cmdmeta.SourceShortcut, false)
cmdmeta.SetAffordanceRef(cmd, shortcut.Service, shortcut.Command)
cmdutil.SetSupportedIdentities(cmd, shortcut.AuthTypes)
registerShortcutFlagsWithContext(ctx, cmd, f, &shortcut)
cmdutil.SetTips(cmd, shortcut.Tips)
@@ -1027,7 +1026,6 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
}
rctx.larkSDK = sdk
applyJSONShorthand(cmd, s)
rctx.Format = rctx.Str("format")
rctx.JqExpr, _ = cmd.Flags().GetString("jq")
return rctx, nil
@@ -1173,75 +1171,6 @@ func registerShortcutFlags(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut)
registerShortcutFlagsWithContext(context.Background(), cmd, f, s)
}
// shortcutDeclaresJSONFlag reports whether the shortcut itself declares a flag
// named "json" in its Flags list (custom semantics, e.g. event +subscribe's
// pretty-print switch or base +record-search's request-body payload).
// Framework-injected flags never appear in s.Flags, so this cleanly separates
// "self-declared json" from "injected shorthand".
func shortcutDeclaresJSONFlag(s *Shortcut) bool {
for _, fl := range s.Flags {
if fl.Name == "json" {
return true
}
}
return false
}
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
// "json": a self-declared format supports it only when its Enum lists "json";
// a framework-injected default format (no format entry in s.Flags) always does.
func shortcutFormatSupportsJSON(s *Shortcut) bool {
for _, fl := range s.Flags {
if fl.Name == "format" {
return slices.Contains(fl.Enum, "json")
}
}
return true // framework-injected: json (default) | pretty | table | ndjson | csv
}
// ensureJSONShorthand registers --json as a shorthand for --format json when:
// 1. the command has a format flag (self-declared or framework-injected), AND
// 2. that format supports "json" (see shortcutFormatSupportsJSON), AND
// 3. no flag named "json" is registered yet — pflag panics on duplicate
// registration, and commands that declare their own --json (event
// +subscribe, base +record-search/-get) keep their custom semantics.
func ensureJSONShorthand(cmd *cobra.Command, s *Shortcut) {
// A shortcut that declares its own "json" flag defines custom semantics
// (e.g. pretty-print switch, request-body payload) — never a shorthand.
if shortcutDeclaresJSONFlag(s) {
return
}
if cmd.Flags().Lookup("format") == nil {
return
}
if !shortcutFormatSupportsJSON(s) {
return
}
// Safety net: pflag panics on duplicate registration.
if cmd.Flags().Lookup("json") != nil {
return
}
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
// applyJSONShorthand folds the injected --json shorthand into the format flag
// itself, before rctx.Format caches it — so both the cached value (OutFormat,
// ValidateJqFlags, dry-run) and later runtime.Str("format") reads observe
// "json". An explicitly passed --format always wins over the shorthand (the
// shorthand only fills in when the user did not choose a format). Shortcuts
// that declare their own "json" flag keep its custom semantics untouched.
func applyJSONShorthand(cmd *cobra.Command, s *Shortcut) {
if shortcutDeclaresJSONFlag(s) {
return
}
if cmd.Flags().Lookup("json") == nil || cmd.Flags().Changed("format") {
return
}
if set, _ := cmd.Flags().GetBool("json"); set {
_ = cmd.Flags().Set("format", "json")
}
}
func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) {
for _, fl := range s.Flags {
desc := fl.Desc
@@ -1305,8 +1234,10 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "pretty", "table", "ndjson", "csv"}, cobra.ShellCompDirectiveNoFileComp
})
if cmd.Flags().Lookup("json") == nil {
cmd.Flags().Bool("json", false, "shorthand for --format json")
}
}
ensureJSONShorthand(cmd, s)
if s.Risk == "high-risk-write" {
cmd.Flags().Bool("yes", false, "confirm high-risk operation")
}

View File

@@ -1,200 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
)
const jsonShorthandUsage = "shorthand for --format json"
func mountTestShortcut(t *testing.T, s Shortcut) *cobra.Command {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "root"}
s.Mount(parent, f)
cmd, _, err := parent.Find([]string{s.Command})
if err != nil {
t.Fatalf("Find() error = %v", err)
}
return cmd
}
// 自定义 format 且 Enum 含 json → 注册简写(本次修复的核心行为)
func TestJSONShorthand_CustomFormatWithJSONEnum_Registered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "mail", Command: "+fake-triage", Description: "x",
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("--json not registered for custom-format shortcut whose Enum contains json")
}
if fl.Usage != jsonShorthandUsage {
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
}
// 默认输出格式不被改变
if def := cmd.Flags().Lookup("format").DefValue; def != "table" {
t.Errorf("format default = %q, want table", def)
}
}
// 自定义 format 但 Enum 不含 json → 不注册
func TestJSONShorthand_CustomFormatWithoutJSONEnum_NotRegistered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "x", Command: "+no-json", Description: "x",
Flags: []Flag{{Name: "format", Default: "csv", Enum: []string{"csv", "table"}, Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
if cmd.Flags().Lookup("json") != nil {
t.Fatal("--json must NOT be registered when format Enum lacks json")
}
}
// 自定义 format 但无 Enum现状 triage 形态)→ 不注册Enum 是判定依据)
func TestJSONShorthand_CustomFormatNoEnum_NotRegistered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "x", Command: "+legacy", Description: "x",
Flags: []Flag{{Name: "format", Default: "table", Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
if cmd.Flags().Lookup("json") != nil {
t.Fatal("--json must NOT be registered when format has no Enum metadata")
}
}
// 自声明 json flagsubscribe 的 pretty / record-search 的请求体)→ 不覆盖、不 panic、语义保留
func TestJSONShorthand_SelfDeclaredJSON_Preserved(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "event", Command: "+fake-subscribe", Description: "x",
Flags: []Flag{
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("self-declared --json missing")
}
if fl.Usage != "pretty-print JSON instead of NDJSON" {
t.Errorf("self-declared --json usage overwritten: %q", fl.Usage)
}
}
// parseMounted mounts the shortcut and parses args against the command's FlagSet
// (registration side effects included), without executing RunE.
func parseMounted(t *testing.T, s Shortcut, args []string) *cobra.Command {
t.Helper()
cmd := mountTestShortcut(t, s)
if err := cmd.ParseFlags(args); err != nil {
t.Fatalf("ParseFlags(%v) error = %v", args, err)
}
return cmd
}
func customFormatShortcut() Shortcut {
return Shortcut{
Service: "mail", Command: "+fake-triage", Description: "x",
Flags: []Flag{{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "fmt"}},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
}
// --json 单独使用 → format 归一化为 json
func TestApplyJSONShorthand_JSONAlone_SetsFormatJSON(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, []string{"--json"})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
t.Fatalf("format = %q, want json", got)
}
}
// 显式 --format 优先于 --json 简写:--format table --json → table
func TestApplyJSONShorthand_ExplicitFormatWins(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, []string{"--format", "table", "--json"})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
t.Fatalf("format = %q, want table (explicit --format must win)", got)
}
}
// --format json --json → json一致无冲突
func TestApplyJSONShorthand_ExplicitJSONFormatConsistent(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, []string{"--format", "json", "--json"})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "json" {
t.Fatalf("format = %q, want json", got)
}
}
// 均不传 → 默认值不变
func TestApplyJSONShorthand_NoFlags_DefaultUntouched(t *testing.T) {
s := customFormatShortcut()
cmd := parseMounted(t, s, nil)
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "table" {
t.Fatalf("format = %q, want table (default untouched)", got)
}
}
// 自声明 string 型 --jsonrecord-search 形态format+json 双声明)→ 归一化跳过
func TestApplyJSONShorthand_SelfDeclaredStringJSON_Skipped(t *testing.T) {
s := Shortcut{
Service: "base", Command: "+fake-record-search", Description: "x",
Flags: []Flag{
{Name: "format", Default: "markdown", Enum: []string{"markdown", "json"}, Desc: "fmt"},
{Name: "json", Desc: "request body JSON object"},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
cmd := parseMounted(t, s, []string{"--json", `{"keyword":"Alice"}`})
applyJSONShorthand(cmd, &s)
if got := cmd.Flags().Lookup("format").Value.String(); got != "markdown" {
t.Fatalf("format = %q, want markdown (self-declared json must not normalize)", got)
}
if got := cmd.Flags().Lookup("json").Value.String(); got != `{"keyword":"Alice"}` {
t.Fatalf("request-body --json corrupted: %q", got)
}
}
// 自声明 bool 型 --jsonsubscribe 形态:无自定义 format框架注入 format→ 归一化跳过
func TestApplyJSONShorthand_SelfDeclaredBoolJSON_Skipped(t *testing.T) {
s := Shortcut{
Service: "event", Command: "+fake-subscribe", Description: "x",
Flags: []Flag{
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
},
Execute: func(context.Context, *RuntimeContext) error { return nil },
}
cmd := parseMounted(t, s, []string{"--json"})
applyJSONShorthand(cmd, &s)
// 注入的 format 默认即 json这里断言的是 Changed 状态未被归一化污染
if cmd.Flags().Changed("format") {
t.Fatal("normalization must not touch format for shortcuts declaring their own --json")
}
}
// 无自定义 format普通命令→ 注入默认 format + 简写(现状回归)
func TestJSONShorthand_DefaultInjectedFormat_StillRegistered(t *testing.T) {
cmd := mountTestShortcut(t, Shortcut{
Service: "im", Command: "+plain", Description: "x",
Execute: func(context.Context, *RuntimeContext) error { return nil },
})
fl := cmd.Flags().Lookup("json")
if fl == nil {
t.Fatal("--json missing on default-format shortcut (regression)")
}
if fl.Usage != jsonShorthandUsage {
t.Errorf("usage = %q, want %q", fl.Usage, jsonShorthandUsage)
}
}

View File

@@ -150,10 +150,12 @@ var ContactSearchUser = common.Shortcut{
{Name: "queries", Desc: "comma-separated keywords searched in parallel; output is a flat users[] with matched_query plus a queries[] sidecar"},
},
Tips: []string{
"Keyword search: lark-cli contact +search-user --query 'alice'",
"Look up by ID (or 'me' for self): lark-cli contact +search-user --user-ids 'ou_xxx,me'",
"Filter-only enumeration — users you've chatted with: lark-cli contact +search-user --has-chatted",
"Refine same-name hits: lark-cli contact +search-user --query '张三' --has-chatted --exclude-external-users",
"Multi-name fanout: lark-cli contact +search-user --queries 'alice,bob,张三'",
"on has_more=true add filters or tighten --query — there is no auto-pagination.",
"open_id is the stable identifier for follow-up commands; on has_more=true add filters or tighten --query — there is no auto-pagination.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateSearchUser(runtime)

View File

@@ -1,336 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
driveListCommentsDefaultPageSize = 50
driveListCommentsDefaultSolvedStatus = "false"
driveListCommentsDefaultScope = "all"
driveListCommentsDefaultUserIDType = "open_id"
)
var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "wiki"}
type driveListCommentsRef struct {
Token string
Type string
SourceFlag string
}
type driveListCommentsTarget struct {
FileToken string
FileType string
}
type driveListCommentsSpec struct {
Ref driveListCommentsRef
PageSize int
PageToken string
SolvedStatus string
CommentScope string
NeedReaction bool
NeedRelation bool
UserIDType string
}
// DriveListComments lists document comments through the Drive comments API,
// while accepting Wiki URLs/tokens and resolving them to the underlying object.
var DriveListComments = common.Shortcut{
Service: "drive",
Command: "+list-comments",
Description: "List comments for doc/docx/sheet/file/slides/base(bitable), with URL parsing and Wiki token unwrapping",
Risk: "read",
Scopes: []string{"docs:document.comment:read"},
ConditionalScopes: []string{"wiki:node:retrieve"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/wiki); Wiki URLs are unwrapped automatically"},
{Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"},
{Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveListCommentsTypes},
{Name: "solved-status", Default: driveListCommentsDefaultSolvedStatus, Desc: "comment solved filter: false=unresolved, true=solved, all=all comments", Enum: []string{"false", "true", "all"}},
{Name: "comment-scope", Default: driveListCommentsDefaultScope, Desc: "comment scope filter: all=all comments, whole=full-document comments, partial=local/selection comments", Enum: []string{"all", "whole", "partial"}},
{Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"},
{Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"},
{Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"},
{Name: "page-token", Desc: "pagination token from previous response"},
{Name: "user-id-type", Default: driveListCommentsDefaultUserIDType, Desc: "user ID type in the response", Enum: []string{"user_id", "union_id", "open_id"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return err
}
return validateDriveListCommentsSpec(spec)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
if err := validateDriveListCommentsSpec(spec); err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return buildDriveListCommentsDryRun(spec)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveListCommentsSpec(runtime)
if err != nil {
return err
}
if err := validateDriveListCommentsSpec(spec); err != nil {
return err
}
target, err := resolveDriveListCommentsTarget(ctx, runtime, spec.Ref)
if err != nil {
return err
}
params := buildDriveListCommentsParams(spec, target.FileType)
path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments", validate.EncodePathSegment(target.FileToken))
data, err := runtime.CallAPITyped("GET", path, params, nil)
if err != nil {
return err
}
runtime.Out(buildDriveListCommentsOutput(target, data), nil)
return nil
},
}
func readDriveListCommentsSpec(runtime *common.RuntimeContext) (driveListCommentsSpec, error) {
ref, err := resolveDriveListCommentsInput(runtime.Str("url"), runtime.Str("token"), runtime.Str("type"))
if err != nil {
return driveListCommentsSpec{}, err
}
return driveListCommentsSpec{
Ref: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
SolvedStatus: strings.TrimSpace(runtime.Str("solved-status")),
CommentScope: strings.TrimSpace(runtime.Str("comment-scope")),
NeedReaction: runtime.Bool("need-reaction"),
NeedRelation: runtime.Bool("need-relation"),
UserIDType: strings.TrimSpace(runtime.Str("user-id-type")),
}, nil
}
func validateDriveListCommentsSpec(spec driveListCommentsSpec) error {
if spec.PageSize < 1 || spec.PageSize > 100 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size")
}
if _, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --solved-status %q; allowed: false, true, all", spec.SolvedStatus).WithParam("--solved-status")
}
if _, ok := driveListCommentsScopeParam(spec.CommentScope); !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --comment-scope %q; allowed: all, whole, partial", spec.CommentScope).WithParam("--comment-scope")
}
if spec.UserIDType != "user_id" && spec.UserIDType != "union_id" && spec.UserIDType != "open_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --user-id-type %q; allowed: user_id, union_id, open_id", spec.UserIDType).WithParam("--user-id-type")
}
return nil
}
func resolveDriveListCommentsInput(urlInput, tokenInput, explicitType string) (driveListCommentsRef, error) {
urlInput = strings.TrimSpace(urlInput)
tokenInput = strings.TrimSpace(tokenInput)
if urlInput != "" && tokenInput != "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url")
}
if urlInput == "" && tokenInput == "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url")
}
raw := urlInput
sourceFlag := "--url"
if raw == "" {
raw = tokenInput
sourceFlag = "--token"
}
inputType := normalizeDriveListCommentsType(strings.ToLower(strings.TrimSpace(explicitType)))
if ref, ok := common.ParseResourceURL(raw); ok {
refType := normalizeDriveListCommentsType(ref.Type)
if inputType != "" && inputType != refType {
return driveListCommentsRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"--type %q conflicts with URL path type %q; remove --type or use a matching value",
inputType,
refType,
).WithParam("--type")
}
if !driveListCommentsTypeSupported(refType) {
return driveListCommentsRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, and wiki",
sourceFlag,
refType,
).WithParam(sourceFlag)
}
return driveListCommentsRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil
}
if strings.Contains(raw, "://") {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag)
}
if strings.ContainsAny(raw, "/?#") {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag)
}
if inputType == "" {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, wiki)", sourceFlag).WithParam("--type")
}
if !driveListCommentsTypeSupported(inputType) {
return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, wiki", inputType).WithParam("--type")
}
return driveListCommentsRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
}
func normalizeDriveListCommentsType(docType string) string {
switch strings.TrimSpace(docType) {
case "base":
return "bitable"
default:
return strings.TrimSpace(docType)
}
}
func driveListCommentsTypeSupported(docType string) bool {
switch normalizeDriveListCommentsType(docType) {
case "doc", "docx", "sheet", "file", "slides", "bitable", "wiki":
return true
default:
return false
}
}
func resolveDriveListCommentsTarget(ctx context.Context, runtime *common.RuntimeContext, ref driveListCommentsRef) (driveListCommentsTarget, error) {
if ref.Type != "wiki" {
return driveListCommentsTarget{FileToken: ref.Token, FileType: ref.Type}, nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token))
data, err := runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": ref.Token},
nil,
)
if err != nil {
return driveListCommentsTarget{}, err
}
node := common.GetMap(data, "node")
objType := normalizeDriveListCommentsType(common.GetString(node, "obj_type"))
objToken := common.GetString(node, "obj_token")
if objType == "" || objToken == "" {
return driveListCommentsTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data")
}
if !driveListCommentsTypeSupported(objType) || objType == "wiki" {
return driveListCommentsTarget{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, and bitable",
objType,
).WithParam(ref.SourceFlag)
}
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
return driveListCommentsTarget{FileToken: objToken, FileType: objType}, nil
}
func buildDriveListCommentsDryRun(spec driveListCommentsSpec) *common.DryRunAPI {
if spec.Ref.Type == "wiki" {
params := buildDriveListCommentsParams(spec, "<obj_type from step 1>")
if spec.NeedRelation {
params["need_relation"] = "<sent only when obj_type is docx>"
}
return common.NewDryRunAPI().
Desc("2-step orchestration: resolve wiki -> list comments").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve wiki node to underlying document").
Params(map[string]interface{}{"token": spec.Ref.Token}).
GET("/open-apis/drive/v1/files/<obj_token from step 1>/comments").
Desc("[2] List comments on resolved document").
Params(params)
}
return common.NewDryRunAPI().
Desc("1-step request: list comments").
GET("/open-apis/drive/v1/files/:file_token/comments").
Params(buildDriveListCommentsParams(spec, spec.Ref.Type)).
Set("file_token", spec.Ref.Token)
}
func buildDriveListCommentsParams(spec driveListCommentsSpec, fileType string) map[string]interface{} {
params := map[string]interface{}{
"file_type": fileType,
"page_size": spec.PageSize,
"user_id_type": spec.UserIDType,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
if value, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); ok && value != nil {
params["is_solved"] = *value
}
if value, ok := driveListCommentsScopeParam(spec.CommentScope); ok && value != nil {
params["is_whole"] = *value
}
if spec.NeedReaction {
params["need_reaction"] = true
}
if spec.NeedRelation && fileType == "docx" {
params["need_relation"] = true
}
return params
}
func driveListCommentsSolvedStatusParam(status string) (*bool, bool) {
switch strings.TrimSpace(status) {
case "false", "":
value := false
return &value, true
case "true":
value := true
return &value, true
case "all":
return nil, true
default:
return nil, false
}
}
func driveListCommentsScopeParam(scope string) (*bool, bool) {
switch strings.TrimSpace(scope) {
case "all", "":
return nil, true
case "whole":
value := true
return &value, true
case "partial":
value := false
return &value, true
default:
return nil, false
}
}
func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} {
items := common.GetSlice(data, "items")
return map[string]interface{}{
"file_token": target.FileToken,
"file_type": target.FileType,
"items": items,
"has_more": common.GetBool(data, "has_more"),
"page_token": common.GetString(data, "page_token"),
"count": len(items),
}
}

View File

@@ -1,365 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func TestResolveDriveListCommentsInput(t *testing.T) {
t.Parallel()
tests := []struct {
name string
urlInput string
rawInput string
docType string
wantResource string
wantType string
wantErr string
wantParam string
}{
{
name: "url docx",
urlInput: "https://example.larksuite.com/docx/docxResource?from=wiki",
wantResource: "docxResource",
wantType: "docx",
},
{
name: "token flag also accepts url",
rawInput: "https://example.larksuite.com/base/bitableResource",
wantResource: "bitableResource",
wantType: "bitable",
},
{
name: "bare wiki token",
rawInput: "wikiResource",
docType: "wiki",
wantResource: "wikiResource",
wantType: "wiki",
},
{
name: "url and token mutually exclusive",
urlInput: "https://example.larksuite.com/docx/docxResource",
rawInput: "docxResource",
wantErr: "mutually exclusive",
wantParam: "--url",
},
{
name: "bare token needs type",
rawInput: "docxResource",
wantErr: "--type is required",
wantParam: "--type",
},
{
name: "type conflicts with url",
urlInput: "https://example.larksuite.com/wiki/wikiResource",
docType: "docx",
wantErr: "conflicts",
wantParam: "--type",
},
{
name: "unsupported url type",
urlInput: "https://example.larksuite.com/drive/folder/folderResource",
wantErr: "unsupported",
wantParam: "--url",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got, err := resolveDriveListCommentsInput(tt.urlInput, tt.rawInput, tt.docType)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
assertDriveListCommentsValidationError(t, err, tt.wantParam)
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got.Token != tt.wantResource || got.Type != tt.wantType {
t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantResource, tt.wantType)
}
})
}
}
func TestValidateDriveListCommentsSpec(t *testing.T) {
t.Parallel()
valid := driveListCommentsSpec{
PageSize: 50,
SolvedStatus: "false",
CommentScope: "all",
UserIDType: "open_id",
}
tests := []struct {
name string
mutate func(*driveListCommentsSpec)
wantParam string
}{
{
name: "invalid page size",
mutate: func(spec *driveListCommentsSpec) {
spec.PageSize = 0
},
wantParam: "--page-size",
},
{
name: "invalid solved status",
mutate: func(spec *driveListCommentsSpec) {
spec.SolvedStatus = "open"
},
wantParam: "--solved-status",
},
{
name: "invalid comment scope",
mutate: func(spec *driveListCommentsSpec) {
spec.CommentScope = "inline"
},
wantParam: "--comment-scope",
},
{
name: "invalid user id type",
mutate: func(spec *driveListCommentsSpec) {
spec.UserIDType = "email"
},
wantParam: "--user-id-type",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
spec := valid
tt.mutate(&spec)
err := validateDriveListCommentsSpec(spec)
if err == nil {
t.Fatal("expected validation error, got nil")
}
assertDriveListCommentsValidationError(t, err, tt.wantParam)
})
}
}
func assertDriveListCommentsValidationError(t *testing.T, err error, wantParam string) {
t.Helper()
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", validationErr.Category, errs.CategoryValidation)
}
if validationErr.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument)
}
if validationErr.Param != wantParam {
t.Fatalf("param = %q, want %q", validationErr.Param, wantParam)
}
if cause := errors.Unwrap(err); cause != nil {
t.Fatalf("unexpected cause on direct validation error: %v", cause)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected errs.ProblemOf to recognize typed error: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("problem category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
}
func TestBuildDriveListCommentsParams(t *testing.T) {
t.Parallel()
defaultSpec := driveListCommentsSpec{
PageSize: 50,
SolvedStatus: "false",
CommentScope: "all",
UserIDType: "open_id",
}
defaultParams := buildDriveListCommentsParams(defaultSpec, "docx")
if got := defaultParams["is_solved"]; got != false {
t.Fatalf("default is_solved = %#v, want false", got)
}
if _, ok := defaultParams["is_whole"]; ok {
t.Fatalf("default params should omit is_whole: %#v", defaultParams)
}
if got := defaultParams["user_id_type"]; got != "open_id" {
t.Fatalf("user_id_type = %#v, want open_id", got)
}
allPartialSpec := driveListCommentsSpec{
PageSize: 100,
PageToken: "next",
SolvedStatus: "all",
CommentScope: "partial",
NeedReaction: true,
NeedRelation: true,
UserIDType: "union_id",
}
allPartialParams := buildDriveListCommentsParams(allPartialSpec, "docx")
if _, ok := allPartialParams["is_solved"]; ok {
t.Fatalf("solved-status all should omit is_solved: %#v", allPartialParams)
}
if got := allPartialParams["is_whole"]; got != false {
t.Fatalf("comment-scope partial is_whole = %#v, want false", got)
}
if got := allPartialParams["need_reaction"]; got != true {
t.Fatalf("need_reaction = %#v, want true", got)
}
if got := allPartialParams["need_relation"]; got != true {
t.Fatalf("need_relation = %#v, want true for docx", got)
}
if got := allPartialParams["page_token"]; got != "next" {
t.Fatalf("page_token = %#v, want next", got)
}
sheetParams := buildDriveListCommentsParams(allPartialSpec, "sheet")
if _, ok := sheetParams["need_relation"]; ok {
t.Fatalf("need_relation should be ignored for non-docx: %#v", sheetParams)
}
}
func TestDriveListCommentsExecuteDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxResource/comments",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("file_type"); got != "docx" {
t.Errorf("file_type = %q, want docx", got)
}
if got := query.Get("is_solved"); got != "false" {
t.Errorf("is_solved = %q, want false", got)
}
if got := query.Get("is_whole"); got != "" {
t.Errorf("is_whole = %q, want omitted", got)
}
if got := query.Get("user_id_type"); got != "open_id" {
t.Errorf("user_id_type = %q, want open_id", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{
{"comment_id": "comment_1", "is_solved": false},
},
"has_more": true,
"page_token": "next",
},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--url", "https://example.larksuite.com/docx/docxResource",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxResource" {
t.Fatalf("file_token = %q, want docxResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
if got := data["count"]; got != float64(1) {
t.Fatalf("count = %#v, want 1", got)
}
}
func TestDriveListCommentsExecuteWikiResolvesToDocx(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("token"); got != "wikiResource" {
t.Errorf("wiki token = %q, want wikiResource", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"node": map[string]interface{}{
"obj_type": "docx",
"obj_token": "docxFromWikiResource",
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/docxFromWikiResource/comments",
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("is_solved"); got != "" {
t.Errorf("is_solved = %q, want omitted for solved-status all", got)
}
if got := query.Get("is_whole"); got != "true" {
t.Errorf("is_whole = %q, want true", got)
}
if got := query.Get("need_relation"); got != "true" {
t.Errorf("need_relation = %q, want true for resolved docx", got)
}
},
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"items": []map[string]interface{}{},
"has_more": false,
},
},
})
err := mountAndRunDrive(t, DriveListComments, []string{
"+list-comments",
"--token", "wikiResource",
"--type", "wiki",
"--solved-status", "all",
"--comment-scope", "whole",
"--need-relation",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := decodeJSONMap(t, stdout.String())
data := mustMapValue(t, out["data"], "data")
if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWikiResource" {
t.Fatalf("file_token = %q, want docxFromWikiResource", got)
}
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" {
t.Fatalf("file_type = %q, want docx", got)
}
}

View File

@@ -623,10 +623,6 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400:
decision.Class = "rate_limited"
decision.Terminal = true
case problem.Code == 1062507:
decision.Class = "parent_sibling_limit"
decision.Terminal = true
decision.Hint = "The destination parent folder has reached its child-count limit. Clean up that folder, choose another --folder-token, or split the upload across subfolders before retrying."
case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043:
decision.Class = "file_size_limit"
case problem.Code == 1062009:

View File

@@ -1334,75 +1334,6 @@ func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
}
}
func TestDrivePushAbortsAfterCreateFolderParentSiblingLimit(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil {
t.Fatalf("MkdirAll a: %v", err)
}
if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil {
t.Fatalf("MkdirAll b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_folder",
Body: map[string]interface{}{
"code": 1062507,
"msg": "parent node out of sibling num.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "parent_sibling_limit" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1062507) || item["subtype"] != "quota_exceeded" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "child-count limit") {
t.Fatalf("hint should explain the destination folder child-count limit, got item=%#v", item)
}
for _, item := range items {
if item["rel_path"] == "b" {
t.Fatalf("parent sibling limit must abort before b, got items=%#v", items)
}
}
}
func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -15,7 +15,6 @@ func Shortcuts() []common.Shortcut {
DrivePreview,
DriveCover,
DriveAddComment,
DriveListComments,
DriveExport,
DriveExportDownload,
DriveImport,

View File

@@ -20,15 +20,14 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+download",
"+preview",
"+cover",
"+add-comment",
"+list-comments",
"+export",
"+export-download",
"+import",
"+version-history",
"+version-get",
"+version-revert",
"+version-delete",
"+add-comment",
"+export",
"+export-download",
"+import",
"+move",
"+delete",
"+status",

View File

@@ -1,112 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
// help 必须列出 --json 简写
func TestMailTriageHelpListsJSONShorthand(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
if err := runMountedMailShortcutWithCobraOutput(t, MailTriage, []string{"+triage", "-h"}, f, stdout); err != nil {
t.Fatalf("help returned error: %v", err)
}
if !strings.Contains(stdout.String(), "shorthand for --format json") {
t.Fatalf("triage help missing --json shorthand\n%s", stdout.String())
}
}
func TestMailWatchHelpListsJSONShorthand(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
if err := runMountedMailShortcutWithCobraOutput(t, MailWatch, []string{"+watch", "-h"}, f, stdout); err != nil {
t.Fatalf("help returned error: %v", err)
}
if !strings.Contains(stdout.String(), "shorthand for --format json") {
t.Fatalf("watch help missing --json shorthand\n%s", stdout.String())
}
}
// 行为验证:--json 走 JSON 输出路径,不输出 table read hint
func TestMailTriageJSONShorthandDoesNotEmitReadHint(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1"}, f, stdout)
if err != nil {
t.Fatalf("triage --json returned error: %v", err)
}
reg.Verify(t)
if strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("--json must follow the JSON path, got table hint\nstderr=%s", stderr.String())
}
if !strings.Contains(stdout.String(), `"messages"`) {
t.Fatalf("--json stdout missing JSON payload\n%s", stdout.String())
}
}
// 等价性验证:--json 与 --format json 的 dry-run 输出一致
func TestMailTriageJSONShorthandDryRunEquivalence(t *testing.T) {
f1, stdout1, _, _ := mailShortcutTestFactory(t)
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--json", "--max", "1", "--dry-run"}, f1, stdout1); err != nil {
t.Fatalf("--json --dry-run error: %v", err)
}
f2, stdout2, _, _ := mailShortcutTestFactory(t)
if err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "json", "--max", "1", "--dry-run"}, f2, stdout2); err != nil {
t.Fatalf("--format json --dry-run error: %v", err)
}
if stdout1.String() != stdout2.String() {
t.Fatalf("dry-run outputs differ:\n--json:\n%s\n--format json:\n%s", stdout1.String(), stdout2.String())
}
}
// 优先级验证:显式 --format table 优先,--json 让位 → 仍走 table 路径
func TestMailTriageExplicitTableWinsOverJSONShorthand(t *testing.T) {
f, stdout, stderr, reg := mailShortcutTestFactory(t)
registerTriageReadHintStubs(reg)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "table", "--json", "--max", "1"}, f, stdout)
if err != nil {
t.Fatalf("triage returned error: %v", err)
}
if !strings.Contains(stderr.String(), "tip: read full content:") {
t.Fatalf("explicit --format table must win over --json (expected table hint)\nstderr=%s", stderr.String())
}
}
// 错误验证Enum 硬校验
func TestMailTriageEnumRejectsUnknownFormat(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailTriage, []string{"+triage", "--format", "bogus", "--max", "1", "--dry-run"}, f, stdout)
if err == nil {
t.Fatal("expected validation error for --format bogus")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error = %T, want typed errs problem carrier", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryValidation)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if ve.Param != "--format" {
t.Fatalf("param = %q, want --format", ve.Param)
}
if !strings.Contains(problem.Message, `invalid value "bogus" for --format`) {
t.Fatalf("message = %q, want enum validation message", problem.Message)
}
if !strings.Contains(problem.Message, "table, json, data") {
t.Fatalf("message = %q, want allowed values list", problem.Message)
}
}

View File

@@ -55,7 +55,7 @@ var MailTriage = common.Shortcut{
Scopes: []string{"mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "format", Default: "table", Enum: []string{"table", "json", "data"}, Desc: "output format: table | json | data (json/data output object with pagination fields)"},
{Name: "format", Default: "table", Desc: "output format: table | json | data (json/data output object with pagination fields)"},
{Name: "max", Type: "int", Default: "20", Desc: "maximum number of messages to fetch (1-400; auto-paginates internally)"},
{Name: "page-size", Type: "int", Desc: "alias for --max"},
{Name: "page-token", Desc: "pagination token from a previous response to fetch the next page"},

View File

@@ -99,7 +99,7 @@ var MailWatch = common.Shortcut{
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "format", Default: "data", Enum: []string{"json", "data"}, Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
{Name: "msg-format", Default: "metadata", Desc: "message payload mode: metadata(headers + meta, for triage/notification) | minimal(IDs and state only, no headers, for tracking read/folder changes) | plain_text_full(all metadata fields + full plain-text body) | event(raw WebSocket event, no API call, for debug) | full(full message including HTML body and attachments)"},
{Name: "output-dir", Desc: "Write each message as a JSON file (always full payload, regardless of --msg-format)"},
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},

View File

@@ -46,8 +46,30 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
- `<task>``<task task-id="GUID"></task>`,必传 task-id任务 guid
- `<chat_card>``<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
- `<sub-page-list>``<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
- `<html5-block>` — 在飞书文档「HTML 块」iframe 里加载的单文件 HTML。
- bitable、base_ref、synced_reference、synced_source、okr — 不可创建,仅支持移动
## html
1. 写入 HTML 内容块时,把 HTML 存为本地 `.html` 文件XML 写 `<html5-block path="@widget.html"></html5-block>`;已有 `data-ref` 时配合 `--reference-map @reference-map.json`。读取时 `<html5-block data-ref="html5_1"></html5-block>` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML若 entry 是 `path`,读取对应 `@doc-fetch-resources/...html` 文件。
2. 格式如下:
```html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="use-iframe" content="true">
<meta name="html-box-height-mode" content="auto">
<meta name="description" content="内容摘要,会导出为 html5-block 的 alt 属性,帮助模型理解该 HTML 块的用途">
<title></title>
</head>
<body>
...
</body>
</html>
```
# 四、块级复制与移动
## 移动block_move_after

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
metadata:
requires:
bins: ["lark-cli"]
@@ -21,13 +21,10 @@ metadata:
## 快速决策
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token先用 `lark-cli drive +inspect` 获取底层 `token``type`,不要把 wiki token 直接当 `file_token``params.file_token` 传源文档 token`data.folder_token` 传目标文件夹 token`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `drive +list-comments --need-relation` 返回评论位置,其他类型会静默忽略该参数;具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解
- 用户给出 doubao.com 的云空间资源 URL/token或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时仍按资源类型、URL 路径和 token 路由到本 skill不要因为域名不是飞书而回退到 WebFetch。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
@@ -81,14 +78,13 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
| 列出文档评论 | URL 或 `file_token` | 优先使用 `drive +list-comments --url '<url>'`wiki URL/token 会自动解析到底层真实 token/type |
| 列出文档评论 | `file_token` | 同添加评论 |
### 评论能力入口
- 添加评论优先使用 [`+add-comment`](references/lark-drive-add-comment.md)review / 审阅 / 校对场景默认尽量创建局部评论,不要把多个可定位问题合并为一条全文评论。
- 获取评论列表优先使用 [`+list-comments`](references/lark-drive-list-comments.md):推荐传 `--url`,支持 wiki 自动解包;参数细节见 reference。
- 评论查询、统计、排序、回复限制,先读 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`;其他文档类型会静默忽略该参数
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md);其他文档类型暂不支持返回定位字段
- reaction / 表情相关操作先读 [`lark-drive-reactions.md`](references/lark-drive-reactions.md);只有用户明确需要 reaction 信息时才带 `need_reaction=true`
- `drive +add-comment``--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`CLI 会将其拆分后写入 `anchor.block_id``anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis``--full-comment`
@@ -141,7 +137,6 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides/base(bitable) 添加评论,也支持解析到这些类型的 wiki URL评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
| [`+list-comments`](references/lark-drive-list-comments.md) | 获取 doc/docx/sheet/file/slides/base(bitable) 评论列表;优先传 URL支持 wiki 自动解包。 |
| [`+export`](references/lark-drive-export.md) | 将 doc/docx/sheet/bitable/slides 导出为本地文件。 |
| [`+export-download`](references/lark-drive-export-download.md) | 根据导出产物的 file_token 下载文件。 |
| [`+import`](references/lark-drive-import.md) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 |
@@ -167,7 +162,7 @@ lark-cli drive <resource> <method> [flags] # 调用 API
> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。
>
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`使用前先读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md),按模板通过 `--params` 传并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
> **高频原生命令:** 读取 Drive 文件夹清单时使用 `drive files list`必须按 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md)模板通过 `--params` 传 `folder_token` / `page_token`并手动处理分页;不要把 `--page-all` 输出直接交给 JSON 解析脚本。
### files
@@ -209,12 +204,10 @@ lark-cli drive <resource> <method> [flags] # 调用 API
### file.statistics
- `get` — 获取文件统计信息
- 获取 docx / 文件统计信息时,建议优先使用 typed flags`lark-cli drive file.statistics get --file-token <token> --file-type <type> --format json``--params` JSON 也支持,适合批量拼装或 raw 参数场景。
### file.view_records
- `list` — 获取文档的访问者记录
- 查看 docx 最近访问记录、返回 open_id、最多 N 条时,建议优先使用 typed flags`lark-cli drive file.view_records list --file-token <docx_token> --file-type docx --page-size <N> --viewer-id-type open_id --format json``--params` JSON 也支持,适合批量拼装、分页续跑或 raw 参数场景。
### file.comment.reply.reactions

View File

@@ -1,27 +1,15 @@
# 文档评论定位字段
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,优先使用 `drive +list-comments --need-relation` 查询 docx 评论位置
当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,docx 文档的评论查询必须带 `need_relation=true`
## 适用范围
- 当前只有 `file_type=docx` 支持通过 `need_relation=true` 查询评论的位置,并返回可用于定位正文 block 的 `relation``parent_type``parent_token` 等字段。
- `drive +list-comments` 会在目标不是 docx 时静默忽略 `--need-relation`,避免把无效参数传给 OpenAPI。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
- 其他文件类型暂不支持通过 `need_relation` 查询评论位置。遇到 sheet、bitable、slides、普通文件等类型的评论时不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。
## 调用方式
分页列出评论时,优先传 URLWiki URL / Wiki token 会自动解析到底层真实 token/type
```bash
lark-cli drive +list-comments --url '<docx_or_wiki_url>' --need-relation
```
如果只有 Wiki token显式传 `--type wiki`
```bash
lark-cli drive +list-comments --token '<wiki_token>' --type wiki --need-relation
```
只有在需要未被 shortcut 暴露的底层参数时,才直接调用 raw OpenAPI。此时把 `need_relation` 放在 query params
分页列出评论时,`need_relation` 放在 query params
```bash
lark-cli drive file.comments list \
@@ -138,7 +126,7 @@ lark-cli docs +fetch --doc '<doc_token_or_url>' --detail with-ids
## 定位流程
1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。
2.`drive +list-comments --need-relation` 获取评论;已知评论 ID 且需要批量查询时,可用 `drive file.comments batch_query` 并带 `need_relation=true`raw `drive file.comments list` 仅作为低层参数兜底。
2.`drive file.comments list` `drive file.comments batch_query` 获取评论,并带 `need_relation=true`
3.`docs +fetch --detail with-ids` 获取文档内容。
4. 对每条评论先看 `relation`
- 如果存在 `relation.relation`,解析这个 JSON 字符串。

View File

@@ -1,6 +1,6 @@
# Drive 评论查询、统计与回复指南
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md)获取评论列表优先使用 [`lark-drive-list-comments.md`](lark-drive-list-comments.md)reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md)reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。
## 评论模式
@@ -16,21 +16,14 @@
## 查询默认口径
优先使用 `drive +list-comments`,不要优先手写 `drive file.comments list`。shortcut 默认 `--solved-status false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论仅当用户明确要求包含已解决评论时,才`--solved-status all`。只查已解决评论时传 `--solved-status true`
`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数
```bash
# 默认查询:仅未解决评论
lark-cli drive +list-comments --url '<DOC_URL>'
# 全部评论:包含已解决和未解决
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status all
# 已解决评论
lark-cli drive +list-comments --url '<DOC_URL>' --solved-status true
# 裸 wiki token
lark-cli drive +list-comments --token '<WIKI_TOKEN>' --type wiki
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx","is_solved":false}'
# 包含已解决评论:仅当用户明确要求时使用
lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx"}'
```
## 评论卡片与统计
@@ -60,13 +53,12 @@ lark-cli drive +list-comments --token '<WIKI_TOKEN>' --type wiki
## batch_query 与 list
- `drive file.comments batch_query` 用于已知评论 ID 后的批量查询,需要传入具体评论 ID 列表。
- `drive +list-comments` 用于分页获取评论列表;如果要统计全量评论数、遍历包含已解决评论在内的所有评论、获取全量最新评论或最后 N 条评论,请先传 `--solved-status all` 并拉完所有分页。它会处理 URL、wiki token 和 token/type 匹配问题
- `drive file.comments list` 是原生命令。需要 shortcut 未暴露的字段时才使用。
- `drive file.comments list` 用于分页获取评论列表,适合统计评论数、遍历所有评论、获取最新或最后 N 条评论等场景
## 评论定位字段
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`
- `--need-relation` 仅 docx 生效;其他文档类型会静默忽略
- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。
- 其他文档类型暂不支持返回定位字段
## 原生 API

View File

@@ -7,18 +7,6 @@
> [!CAUTION]
> 这是**高风险写操作**。CLI 层要求显式传 `--yes`;如果用户已经明确要求删除且目标明确,直接执行并带上 `--yes`。
> “目标明确”表示用户给出了可解析为 `file-token` + `type` 的具体 URL/token或对你刚列出的可解析资源列表逐项/整批确认删除。按“没用的”“临时的”“疑似重复的”“全部旧文件”等描述搜索出来的候选属于待确认目标;这类请求先列候选、说明筛选依据和影响范围,然后停止等待确认。
## 删除前门槛
执行 `drive +delete --yes` 前同时满足:
| 条件 | 可执行信号 |
|------|------------|
| 具体目标 | 单个可解析为 `file-token` + `type` 的 URL/token或用户确认过且可解析的资源列表 |
| 执行确认 | 用户在本轮明确说确认删除这些具体目标 |
若缺少任一条件,使用 `drive +search``drive +inspect` 或只读 API 收集候选并回复待确认清单启发式规则打开时间、标题模式、owner、文件类型等只能作为候选筛选依据不能升级为删除确认。执行 `drive +delete` 时必须使用解析后的 `--file-token``--type`
## 命令

View File

@@ -41,37 +41,12 @@ lark-cli drive files list \
也可以省略 `folder_token` 字段来请求根目录,但在 Agent 编排中建议显式传空字符串,避免把“忘记传参数”和“确认请求根目录”混在一起。
## 按时间排序
默认不要传 `order_by` / `direction`;服务端会按默认顺序返回。只有用户明确要求按创建时间或编辑时间排序时,才使用服务端排序参数。
按创建时间升序列出当前文件夹直接子项:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","order_by":"CreatedTime","direction":"ASC","page_size":200}' \
--format json
```
按编辑时间降序列出当前文件夹直接子项:
```bash
lark-cli drive files list \
--params '{"folder_token":"<folder_token>","order_by":"EditedTime","direction":"DESC","page_size":200}' \
--format json
```
以上示例返回排序后的当前页;如果返回 `has_more=true`,保持相同 `folder_token` / `order_by` / `direction` / `page_size`,把 `next_page_token` 放入 `page_token` 继续翻页。
## 参数规则
1. `folder_token` 必须放在 `--params` JSON 里;不要使用不存在的 `--folder-token` flag。
2. `page_token` 必须放在 `--params` JSON 里;不要依赖 shell 变量拼接不完整的 JSON。
3. 默认不要传 `order_by` / `direction`;只有用户明确要求按创建时间 / 编辑时间排序时才使用服务端排序参数
4. 排序参数映射:创建时间 -> `order_by:"CreatedTime"`;编辑时间 / 修改时间 -> `order_by:"EditedTime"`;升序 -> `direction:"ASC"`;降序 -> `direction:"DESC"`。不要省略排序参数后再用 Python / shell 客户端排序替代
5. 排序查询建议带 `page_size:200` 减少翻页;只有用户要求完整分页、递归盘点、大目录全量导出,或当前页返回 `has_more=true` 后继续翻页时,才加入 `page_token`
6. `page_size` 在分页、递归盘点或全量导出时建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因。
7. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构。
3. `page_size` 建议显式设置为 `200`。如果服务端或环境返回参数错误,再降级到服务端允许的值,并记录降级原因
4. 调用前如果不确定字段结构,先运行 `lark-cli schema drive.files.list` 查看 `--params` 结构
## 返回结构与解析

View File

@@ -47,6 +47,4 @@ JSON 输出包含以下字段:
- `--url` 为必填参数
-`--url` 是 bare token非完整 URL`--type` 也是必填的
- wiki URL 会自动调用 `get_node` API 解包,输出中 `type``token` 是底层文档的类型和 token
- `+inspect` 只用于识别/消歧;如果任务已能通过 URL 路径形态完成路由判断,不必把它作为所有 Drive 操作的通用前置步骤
- `+inspect` 失败后不要自动切到写接口继续尝试先按错误提示处理权限、scope 或链接问题
- 支持 `--dry-run` 查看将调用的 API 步骤

View File

@@ -1,106 +0,0 @@
# drive +list-comments
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。
列出 doc/docx/sheet/file/slides/base(bitable) 的评论卡片。优先传 URLshortcut 会自动识别类型;如果传 wiki URL 或 `--token <wiki_token> --type wiki`,会先解析到真实文档。原生 `drive file.comments list` 仍保留用于需要特殊参数的场景。
## 命令
```bash
# 推荐:直接传 URL。默认只查未解决评论。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>"
# 查询全部评论,包括已解决和未解决。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--solved-status all
# 查询已解决评论。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--solved-status true
# 只查全文评论或局部评论。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--comment-scope whole
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--comment-scope partial
# wiki URL 会自动解包。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>"
# 裸 wiki token 也支持,但必须显式声明 --type wiki。
lark-cli drive +list-comments \
--token "<WIKI_TOKEN>" \
--type wiki
# 裸真实 token 需要声明真实类型。
lark-cli drive +list-comments \
--token "<DOCX_TOKEN>" \
--type docx \
--page-size 100
# docx 需要评论定位关系时再带 need-relation非 docx 会静默忽略。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--need-relation
# 分页续跑。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/docx/<DOCX_TOKEN>" \
--page-size 100 \
--page-token "<NEXT_PAGE_TOKEN>"
# 预览请求链路,不发真实请求。
lark-cli drive +list-comments \
--url "https://example.larksuite.com/wiki/<WIKI_TOKEN>" \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/wiki URLwiki URL 会自动解析到真实文档。 |
| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`wiki token 使用 `--type wiki`。 |
| `--type` | 裸 token 时必填 | `doc``docx``sheet``file``slides``bitable``base``wiki`。传 `base`CLI 会按 `bitable` 类型处理。 |
| `--solved-status` | 否 | `false` / `true` / `all`,默认 `false``false` 查未解决评论;`true` 查已解决评论;`all` 查全部评论。 |
| `--comment-scope` | 否 | `all` / `whole` / `partial`,默认 `all``all` 查全部范围;`whole` 查全文评论;`partial` 查局部评论。 |
| `--need-reaction` | 否 | 是否返回评论卡片上的 reaction 数据;只有用户明确需要 reaction 时才带。 |
| `--need-relation` | 否 | docx 评论定位关系字段;仅 docx 生效,非 docx 静默忽略。需要定位正文时先读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。 |
| `--page-size` | 否 | 默认 50最大 100。 |
| `--page-token` | 否 | 分页游标;本 shortcut 不自动翻页,按返回的 `page_token` 继续请求下一页。 |
| `--user-id-type` | 否 | `user_id` / `union_id` / `open_id`,默认 `open_id`。 |
## 行为说明
- 默认 `--solved-status false`,即默认只查未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论;仅当用户明确要求包含已解决评论时,才传 `--solved-status all`
- `--comment-scope all` 查全部范围;`whole` 查全文评论;`partial` 查局部/选区评论。
- URL 输入时不需要传 `--type`;如果 URL 类型和显式 `--type` 冲突shortcut 会返回 validation error建议移除 `--type`
- wiki 输入会自动解析到真实文档再查询评论列表。JSON 输出不额外返回 wiki token 或 wiki node。
- 输出中的 `items` 保留评论卡片字段,外层补充 `file_token``file_type``has_more``page_token``count``count` 是当前页返回的评论卡片数。
- 如果需要批量按评论 ID 查询、获取更多回复、创建/编辑/删除回复,继续使用原生 `drive file.comments batch_query``drive file.comment.replys.*`
## 输出
```json
{
"file_token": "docx_token",
"file_type": "docx",
"items": [],
"has_more": false,
"page_token": "",
"count": 0
}
```
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-drive-comments-guide](lark-drive-comments-guide.md) -- 评论统计、回复限制和原生 API 说明
- [lark-drive-comment-location](lark-drive-comment-location.md) -- 使用 `need_relation` 定位 docx 正文

View File

@@ -10,18 +10,6 @@
如果用户只是想向文档 owner 申请访问权限,优先使用 [`lark-drive-apply-permission.md`](lark-drive-apply-permission.md)。
## 公开权限修改前门槛
公开权限修改是高风险写操作。执行 `drive permission.public patch --yes` 前同时确认:
| 条件 | 可执行信号 |
|------|------------|
| 具体目标 | 单个 URL/token或用户确认过的资源列表 |
| 公开范围 | 用户明确选择组织内/互联网、可读/可编辑等具体 `link_share_entity` 档位 |
| 执行确认 | 用户在本轮确认按该目标和范围执行 |
“开放一下”“共享给大家”“让大家能看”只表达目标状态不包含具体公开范围。先列出可选范围并停止等待用户选择公开档位必须来自用户选择CLI 的 `--yes` 只表示已获得用户对该档位的执行确认。
## 公开权限错误码
调用 `lark-cli drive permission.public patch` 更新文档公开权限失败时,如果返回以下错误码,按表格给用户明确下一步。不要把这些错误简单归类为缺少 scope它们通常表示租户、对外分享或文档密级策略拦截。

View File

@@ -143,7 +143,6 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
| `permission_denied` | `1061004` / HTTP 403 | 当前身份无权操作目标资源 | 停止重试检查目标文件夹权限、身份类型user / bot和资源可见性 |
| `invalid_api_parameters` | `1061002` | API 参数被服务端拒绝 | 停止重试,检查 `--folder-token`、覆盖模式、`file_token`、文件名和上传参数;不要对同一参数组合批量重试 |
| `parent_node_missing` | `1061044` | 上传 / 建目录使用的父文件夹不存在或当前身份不可见 | 停止重试,检查 `--folder-token` 是否仍存在、是否有权限、父目录是否在 push 过程中被删除;不要继续上传同一目录树 |
| `parent_sibling_limit` | `1062507` | 目标父文件夹单层子节点数量超过上限 | 停止重试,清理目标目录、换一个 `--folder-token`,或把上传内容拆到多个子目录 |
| `rate_limited` | `99991400` | 触发频控 | 停止当前批次,退避后再重试 |
| `server_error` | `1061001` / `2200` | Drive 服务端异常 | 停止当前批次,稍后重试;保留 `log_id` 便于排查 |

View File

@@ -1,12 +1,6 @@
# 知识整理工作流
Workflow id: `knowledge_organize`
Risk / Structure: `R2-R3` / `S3`
This file implements the registered knowledge organization workflow. Before execution, the agent MUST read [`lark-drive-workflow.md`](lark-drive-workflow.md) and [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md), and follow the shared execution protocol, Artifact Contract, Workflow Loading rules, authentication rules, and write confirmation rules.
It defines the workflow-specific state machine and progressive loading map. Stage-specific rules live in phase files and MUST be loaded only when the workflow reaches the corresponding state.
This file is the single entry point for the knowledge organization workflow. It defines the global contract, state machine, and progressive loading map. Stage-specific rules live in phase files and MUST be loaded only when the workflow reaches the corresponding state.
Phase files are references for this workflow, not independent skills. Do not route user requests directly to a phase file.
@@ -86,7 +80,7 @@ When this workflow is triggered, the agent MUST:
## Runtime State
This workflow extends the shared Artifact Contract. Agent MUST maintain these internal fields during one workflow run:
Agent MUST maintain these internal fields during one workflow run:
| Field | Meaning |
|-------|---------|
@@ -120,24 +114,24 @@ This workflow extends the shared Artifact Contract. Agent MUST maintain these in
## Execution State Machine
| State | Protocol Step | Entry Condition | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|-------|---------------|-----------------|---------------|--------------------|---------------|------------|
| `PARSE_SCOPE` | `route` / `scope` | Workflow triggered | Load discovery phase; parse target, environment, identity, and target type | Scope confirmation or clarification question | `true` | `INVENTORY` |
| `INVENTORY` | `read` | Scope confirmed | Load discovery phase; recursively list resources and build `resource_items` | Inventory progress / summary; continue automatically unless blocked | `false` unless blocked | `CONTENT_READ` |
| `CONTENT_READ` | `read` | Inventory complete | Load analysis phase; identify low-confidence items and perform mandatory partial read when needed | Low-confidence read summary | `false` unless auth / permission blocks | `ISSUE_ANALYSIS` |
| `ISSUE_ANALYSIS` | `assess` / `plan` | Resource list and partial reads ready | Load analysis phase; detect structure problems, evidence, and organization approach | Inventory result, problems, organization approach, and decision options | `true` | `RULE_GENERATION` |
| `RULE_GENERATION` | `assess` / `plan` | User confirms organization approach | Load analysis phase; generate classification rules and `target_tree` | No separate stop; target tree is shown with plan generation | `false` | `PLAN_GENERATION` |
| `PLAN_GENERATION` | `assess` / `plan` | Target tree ready | Load planning phase; generate complete internal `plan_items`; show target tree plus plan overview or page | Target tree and plan overview / paginated plan page | `true` | `EXEC_CONFIRM` |
| `EXEC_CONFIRM` | `confirm` | User wants execution | Load planning phase; ask user to choose execution scope | Execution options and write-operation summary | `true` | `EXECUTE` or `DONE` |
| `EXECUTE` | `execute` | User explicitly confirmed execution scope | Load execution phase; execute only whitelisted write operations for confirmed scope while maintaining internal recovery state | Progress reports for large or long-running execution; if blocked after successful moves, ask whether to try restoring to `整理前的位置` | `false` unless blocked / recovery offered | `VERIFY`, `ROLLBACK_CONFIRM`, or `DONE` |
| `VERIFY` | `verify` | Execution finished | Load execution phase; rescan target scope and compare actual path/token against plan | Verification table and final summary; if serious mismatches exist, ask whether to try restoring to `整理前的位置` | `false` unless recovery offered | `DONE` or `ROLLBACK_CONFIRM` |
| `ROLLBACK_CONFIRM` | `recovery confirm` | User asks to restore after execution failure / verification mismatch / explicit rollback request | Load rollback phase; generate internal `rollback_plan`; ask whether to execute recovery | Recoverable scope and restore confirmation | `true` | `ROLLBACK` or `DONE` |
| `ROLLBACK` | `recovery execute` | User explicitly confirms restore execution | Load rollback phase; execute confirmed reverse moves only | Recovery progress / result | `false` | `ROLLBACK_VERIFY` |
| `ROLLBACK_VERIFY` | `recovery verify` | Recovery execution finished | Load rollback phase; verify restored locations and decide whether cleanup candidates exist | Recovery verification result | `false` | `ROLLBACK_CLEANUP_CONFIRM` or `DONE` |
| `ROLLBACK_CLEANUP_CONFIRM` | `cleanup confirm` | Cleanup candidates exist after recovery, or user asks to clean workflow-created empty folders / nodes | Load rollback phase; generate cleanup plan and ask for delete confirmation | Cleanup candidates and delete confirmation | `true` | `ROLLBACK_CLEANUP` or `DONE` |
| `ROLLBACK_CLEANUP` | `cleanup execute` | User explicitly confirms cleanup deletion | Load rollback phase; delete only confirmed workflow-created safe-empty folders / nodes | Cleanup progress / result | `false` | `ROLLBACK_CLEANUP_VERIFY` |
| `ROLLBACK_CLEANUP_VERIFY` | `cleanup verify` | Cleanup deletion finished | Load rollback phase; verify deleted cleanup targets | Cleanup verification result | `false` | `DONE` |
| `DONE` | `done` | No more action | Stop | Final answer | `false` | End |
| State | Entry Condition | Agent MUST Do | User-Facing Output | wait_for_user | Next State |
|-------|-----------------|---------------|--------------------|---------------|------------|
| `PARSE_SCOPE` | Workflow triggered | Load discovery phase; parse target, environment, identity, and target type | Scope confirmation or clarification question | `true` | `INVENTORY` |
| `INVENTORY` | Scope confirmed | Load discovery phase; recursively list resources and build `resource_items` | Inventory progress / summary; continue automatically unless blocked | `false` unless blocked | `CONTENT_READ` |
| `CONTENT_READ` | Inventory complete | Load analysis phase; identify low-confidence items and perform mandatory partial read when needed | Low-confidence read summary | `false` unless auth / permission blocks | `ISSUE_ANALYSIS` |
| `ISSUE_ANALYSIS` | Resource list and partial reads ready | Load analysis phase; detect structure problems, evidence, and organization approach | Inventory result, problems, organization approach, and decision options | `true` | `RULE_GENERATION` |
| `RULE_GENERATION` | User confirms organization approach | Load analysis phase; generate classification rules and `target_tree` | No separate stop; target tree is shown with plan generation | `false` | `PLAN_GENERATION` |
| `PLAN_GENERATION` | Target tree ready | Load planning phase; generate complete internal `plan_items`; show target tree plus plan overview or page | Target tree and plan overview / paginated plan page | `true` | `EXEC_CONFIRM` |
| `EXEC_CONFIRM` | User wants execution | Load planning phase; ask user to choose execution scope | Execution options and write-operation summary | `true` | `EXECUTE` or `DONE` |
| `EXECUTE` | User explicitly confirmed execution scope | Load execution phase; execute only whitelisted write operations for confirmed scope while maintaining internal recovery state | Progress reports for large or long-running execution; if blocked after successful moves, ask whether to try restoring to `整理前的位置` | `false` unless blocked / recovery offered | `VERIFY`, `ROLLBACK_CONFIRM`, or `DONE` |
| `VERIFY` | Execution finished | Load execution phase; rescan target scope and compare actual path/token against plan | Verification table and final summary; if serious mismatches exist, ask whether to try restoring to `整理前的位置` | `false` unless recovery offered | `DONE` or `ROLLBACK_CONFIRM` |
| `ROLLBACK_CONFIRM` | User asks to restore after execution failure / verification mismatch / explicit rollback request | Load rollback phase; generate internal `rollback_plan`; ask whether to execute recovery | Recoverable scope and restore confirmation | `true` | `ROLLBACK` or `DONE` |
| `ROLLBACK` | User explicitly confirms restore execution | Load rollback phase; execute confirmed reverse moves only | Recovery progress / result | `false` | `ROLLBACK_VERIFY` |
| `ROLLBACK_VERIFY` | Recovery execution finished | Load rollback phase; verify restored locations and decide whether cleanup candidates exist | Recovery verification result | `false` | `ROLLBACK_CLEANUP_CONFIRM` or `DONE` |
| `ROLLBACK_CLEANUP_CONFIRM` | Cleanup candidates exist after recovery, or user asks to clean workflow-created empty folders / nodes | Load rollback phase; generate cleanup plan and ask for delete confirmation | Cleanup candidates and delete confirmation | `true` | `ROLLBACK_CLEANUP` or `DONE` |
| `ROLLBACK_CLEANUP` | User explicitly confirms cleanup deletion | Load rollback phase; delete only confirmed workflow-created safe-empty folders / nodes | Cleanup progress / result | `false` | `ROLLBACK_CLEANUP_VERIFY` |
| `ROLLBACK_CLEANUP_VERIFY` | Cleanup deletion finished | Load rollback phase; verify deleted cleanup targets | Cleanup verification result | `false` | `DONE` |
| `DONE` | No more action | Stop | Final answer | `false` | End |
## Progressive Load Map

View File

@@ -97,7 +97,7 @@ Structure Level
2. Entry file 超过约 300 行时,优先拆 `commands``outputs``artifacts` reference。
3. 只有执行、验证、恢复或 rollback 状态链复杂到影响可读性时,才升级到 `S3` phase files。
4. 垂直业务包优先作为已有 workflow 的 recipe / policy / template不默认新增独立 workflow。
5. 已有样板:`permission_governance``R2/S2``knowledge_organize``R2-R3/S3`
5. 已有样板:`permission_governance``R2/S2`已发布的独立 `knowledge_organize``R2-R3/S3`,当前不作为本总框架 registry entry
## 加载与拆分边界
@@ -111,7 +111,6 @@ Structure Level
| Workflow | Status | Risk | Structure | Entry File | Trigger |
|----------|--------|------|-----------|------------|---------|
| `permission_governance` | Registered | `R2` | `S2` | [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) | 权限审计、公开链接/外部访问、复制/下载/评论/分享设置、权限申请、owner 转移 / 批量 owner 转移、密级标签调整 |
| `knowledge_organize` | Registered | `R2-R3` | `S3` | [`lark-drive-workflow-knowledge-organize.md`](lark-drive-workflow-knowledge-organize.md) | 整理云盘 / 文件夹 / 文档库 / 知识库、盘点目录结构、归类资源、生成整理方案,并在用户确认后创建目录或移动资源 |
## Workflow Loading

View File

@@ -47,7 +47,7 @@ lark-cli mail +watch --print-output-schema
|------|------|------|
| `--mailbox <id>` | `me` | 订阅目标邮箱 |
| `--msg-format <mode>` | `metadata` | 输出模式:`metadata` / `minimal` / `plain_text_full` / `full` / `event` |
| `--format <mode>` | `data` | 输出样式:`json`(带 ok/data 信封的 NDJSON 流)/ `data`(裸 NDJSON 流) |
| `--format <mode>` | `table` | 输出样式:`table` / `json` / `data` |
| `--folder-ids <json-array>` | — | 文件夹 ID 过滤,如 `["INBOX","SENT"]` |
| `--folders <json-array>` | — | 文件夹名称过滤(与 `--folder-ids` 取并集) |
| `--label-ids <json-array>` | — | 标签 ID 过滤,如 `["FLAGGED","IMPORTANT"]` |

View File

@@ -7,7 +7,7 @@
## Core Rules
- `asset_need` is metadata only. It can guide page design, but it must not require web search, local download, media upload, or external tools.
- Every planned asset must include a fallback visual plan. The fallback can use native charts, tables, whiteboard diagrams, placeholder regions, or XML shapes, text, and arrows as appropriate.
- Every planned asset must include a fallback visual plan so the slide can be generated with XML shapes, text, arrows, tables, simple charts, whiteboard diagrams, or placeholder regions.
- Asset needs must serve the page's `key_message` and `visual_focus`. Do not add decorative assets that do not clarify the page.
- Prefer a few high-value asset plans over one asset on every page. For a 6-page technical or business deck, plan assets on at least 3 pages when the content allows.
- If a real local asset already exists or the user provides one, it can be used through the normal media-upload workflow. Still keep `fallback_if_missing` in the plan.
@@ -43,7 +43,7 @@ For a page without a meaningful asset need, use:
- `architecture_diagram`: system components, data flow, dependency map, or model structure.
- `icon`: small semantic symbol for a concept, step, role, or status.
- `logo`: brand, product, team, or customer mark.
- `chart`: column, bar, line, area, radar, pie, doughnut/ring, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
- `chart`: line, bar, pie, radar, area, or combo data visual. Note: `<chart>` does not support funnel or scatter — map those to `<whiteboard>` SVG at generation time.
- `infographic`: composed visual explanation, usually combining labels, numbers, and simple shapes.
- `screenshot`: product UI, terminal output, workflow state, or page capture.
- `flow_diagram`: process, sequence, decision tree, or mechanism diagram.
@@ -64,23 +64,11 @@ Match asset type to slide role:
`suggested_query` is only a future lookup hint. Write it as a short phrase a human or later workflow could search, but do not execute the search unless the user separately requests real assets.
For `asset_type: "chart"`:
- If the visual is a supported standard data chart — column, bar, line, area, radar, pie, doughnut/ring, or combo — `fallback_if_missing` must still render as a native `<chart>`.
- Do not imitate supported standard data visuals with manual drawing primitives or `<whiteboard>`.
- Choose the data source explicitly:
- `user_provided`: when the user provides concrete values, tables, CSV, or metric lists, use those values and do not replace them with mock data.
- `mock_placeholder`: when the user asks for a placeholder, template, example, or chart position to replace later, use mock data in a native `<chart>`.
- `mock_required_by_intent`: when the user does not provide concrete values but asks for data expression, charts, trends, comparisons, or distributions, use mock data in a native `<chart>`.
- Mock data must be labeled as `模拟数据,仅占位,待替换真实数据` or equivalent. Do not present mock values as facts.
- Manual drawing fallbacks are allowed only for unsupported chart types such as scatter, funnel, waterfall-like custom visuals, or decorative non-data visuals.
`fallback_if_missing` must be concrete enough to turn into XML, for example:
- "Draw a simplified attention matrix with 5 token labels, semi-transparent cells, and arrows to output token."
- "Use three grouped boxes with arrows from client to gateway to service; add small protocol labels."
- "Render a native `<chart>` using the user-provided series."
- "Render a native `<chart>` with mock placeholder values and label it as `模拟数据,仅占位,待替换真实数据`."
- "Render a mini bar chart with 4 bars using shapes and value labels."
- "Use a bordered placeholder panel with product area labels, not an empty image."
Weak fallbacks to avoid:
@@ -130,7 +118,7 @@ Business comparison page:
When generating XML:
1. If an asset exists and the workflow supports it, place it in the planned visual region.
2. If no asset exists, immediately render `fallback_if_missing` with the planned XML-native element type. Supported standard data visuals still use native `<chart>`; other fallbacks may use shapes, text, lines, arrows, tables, whiteboard diagrams, or placeholder panels.
2. If no asset exists, immediately render `fallback_if_missing` with XML-native shapes, text, lines, arrows, tables, whiteboard diagrams, or chart-like elements.
3. Size the fallback to satisfy `visual_focus`; it should be a real page element, not a tiny decoration.
4. Keep text-density limits. Do not compensate for missing assets by adding long bullet text.
5. After creation, fetch the presentation and verify asset pages are not blank and that each planned fallback is visible when no real asset was used.

View File

@@ -2,8 +2,6 @@
`<whiteboard>` 放在 `<data>` 内,内部可放 **SVG****Mermaid**,用于绘制流程图、时序图、架构图、散点图、漏斗图、自定义图标、装饰图案等 `<chart>``<shape>` 难以覆盖的视觉内容。
普通柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图和组合图应优先使用原生 `<chart>`。除非用户明确要求像素级自定义,或图表类型确实不受 `<chart>` 支持,否则不要用 `<whiteboard>` + SVG / Mermaid 重画这些标准图表。
> 前置条件:使用本文档前先阅读 [lark-slides SKILL.md](../SKILL.md)。
---
@@ -14,13 +12,13 @@
| 场景 | 推荐元素 |
|------|---------|
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/环/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
| 散点图、漏斗图(`<chart>` 不支持)或其他非原生数据视觉 | `<whiteboard>` SVG |
| 有结构化数据序列的柱/条/折线/面积/雷达/饼/组合图 | `<chart>` — 原生渲染,支持 legend / tooltip / 系列配色 |
| 散点图、漏斗图(`<chart>` 不支持) | `<whiteboard>` SVG |
| 流程图、时序图、架构图、类图、ER 图等拓扑图 | `<whiteboard>` Mermaid 或 SVG |
| 自定义图标、徽标、示意性图形(需要 path/polygon 精确控制) | `<whiteboard>` SVG |
| 进度条、波浪背景、装饰图案、像素级自定义可视化 | `<whiteboard>` SVG |
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG / Mermaid 手绘——原生渲染更省力、结构更稳定,也更容易被回读和后续编辑
> 适合 `<chart>` 的内容就用 `<chart>`,不要用 SVG 手绘——原生渲染更省力且质量更高
---
@@ -41,13 +39,9 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
## SVG 还是 Mermaid
选择分步:**先排除原生 `<chart>`,再判断 whiteboard 类型,最后看当前模型身份**。
选择分步:**先看图表类型,看当前模型身份**。
### 第一步:先确认是否应该使用 `<chart>`
如果内容是柱状图、条形图、折线图、面积图、雷达图、饼图 / 环图或组合图,返回使用原生 `<chart>`,不要继续套用本文档的 SVG / Mermaid 路径。
### 第二步whiteboard 类型优先判断
### 第一步:图表类型优先判断
以下类型**推荐 Mermaid**,自动布局、代码简洁;如需精确匹配品牌配色或自定义节点样式,可改用 SVG
@@ -56,19 +50,20 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
| 流程图、决策树、架构图 | `flowchart TD` / `flowchart LR` |
| 时序图 | `sequenceDiagram` |
| 类图 | `classDiagram` |
| 饼图 | `pie` |
| 甘特图 | `gantt` |
| 状态图 | `stateDiagram-v2` |
| 思维导图 | `mindmap` |
| ER 图 | `erDiagram` |
### 第步:非原生图表与装饰元素按模型身份选路径
### 第步:数据图表与装饰元素按模型身份选路径
上表以外的场景散点图、漏斗图、进度条、时间线、波浪背景、星点纹理等需要精确控制坐标和配色SVG 表达力更强,但各模型生成 SVG 的能力有差异:
| 模型身份 | 路径 |
|----------|------|
| Claude / Gemini / GPT / GLM | **SVG** — 精确控制坐标、颜色、透明度 |
| Doubao / Seed / Other | **Mermaid** — 用 `gantt``flowchart` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
| Doubao / Seed / Other | **Mermaid** — 用 `pie``gantt` 等近似表达;确实无法用 Mermaid 表达时才回退到简单 SVG 矩形/线条 |
> **先自报身份再选路径**:在决定使用 SVG 之前,确认当前模型属于哪一类。不要跳过这一步。
@@ -78,13 +73,13 @@ SVG 内的坐标相对于 whiteboard 自身左上角0,0与 slide 坐标
### ⚠️ 设计品质要求
在 slide 里嵌入 `<whiteboard>` 的目的是**表达原生 `<chart>` 或基础 `<shape>` 难以覆盖的视觉关系**,不是把标准数据图表手绘一遍
在 slide 里嵌入 `<whiteboard>` 的目的是**提升视觉质量**,不是把数字堆进去
- **不要只用矩形加文字应付**:通篇纯白底色 + 方块 + 黑字等于白做,这是不及格输出
- **非原生数据视觉必须有坐标系**散点、漏斗等仍要有必要的坐标轴、刻度、数值标注或分段说明,不要只画点或色块
- **数据图表必须有坐标系**:坐标轴、网格线、数值标注缺一不可,不要只画柱子或
- **字号必须有层级**:标题 ≠ 标签 ≠ 数值,混用同一字号会消灭视觉焦点
- **配色要与 slide 主题呼应**:深色 slide 背景下图表用透明底或深色卡片;浅色背景下避免再加纯白底块
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、清晰分组、节点状态等细节拉开与默认模板的差距
- **每个 whiteboard 都是设计机会**:主动用圆角、半透明填充、折线面积、点装饰等细节拉开与默认模板的差距
- **写 SVG 前先判断背景亮度**:背景亮度 < 30% 时,装饰元素"对比不足"比"过强"危害更大,宁重勿轻;
- **装饰层次用亮度跳跃,不用线性叠透明度**`α=0.04→0.08→0.12` 的等差递增在深色底上几乎看不出差异(相邻层亮度差 ≈20正确做法是非线性跳跃如 `0.10→0.40→0.70→1.0`,相邻层亮度差 ≥60。
@@ -111,11 +106,11 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
| 元素 | 说明 | 典型用途 |
|------|------|---------|
| `<rect>` | 矩形,支持 `rx` 圆角 | 卡片、进度条、分段色块 |
| `<rect>` | 矩形,支持 `rx` 圆角 | 柱图、卡片、进度条 |
| `<circle>` | 圆 | 节点、装饰点、环形图 |
| `<ellipse>` | 椭圆 | 自定义轮廓图形 |
| `<line>` | 直线 | 轴线、分隔线、连接线 |
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、线、弧形 |
| `<line>` | 直线 | 坐标轴、分隔线 |
| `<path>` | 任意路径(支持 Q/C 曲线) | 波浪、线、弧形 |
| `<text>` | 文本,支持中文 | 标签、数值 |
| `<polygon>` | 多边形 | 箭头、星形、面积填充 |
| `<g>` | 分组 | 批量变换、语义分组 |
@@ -128,25 +123,27 @@ whiteboard 渲染时以**所有子元素的几何包围盒合并结果**为内
---
### 元素计算
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围包括散点、漏斗、装饰性点阵、等间距圆、重复图案等;普通柱状图、折线图、饼图仍应回到原生 `<chart>`
SVG 中只要涉及批量定位、等间距排布或数据映射,**建议额外运行一个 Python 脚本把坐标算出来再填入 SVG**,而不是手动估值。适用范围不限于数据图表——装饰性点阵、等间距圆、重复图案同样适用
> **主动去算**:写 SVG 之前先运行脚本,把输出当注释贴在 `<svg>` 开头,再照着填坐标。估值几乎每次都需要反复调整,跳过这步反而更慢。
**散点图 / 装饰性点阵范式**
**数据图表(柱状图范式**
```python
W, H = 360, 260
origin_x, origin_y = 50, 216 # 左下角SVG Y 轴向下
cw, ch = 290, 184
points = [(12, 40), (28, 80), (45, 65)]
x_min, x_max, y_min, y_max = 0, 50, 0, 100
for i, (xv, yv) in enumerate(points):
x = round(origin_x + (xv - x_min) / (x_max - x_min) * cw)
y = round(origin_y - (yv - y_min) / (y_max - y_min) * ch)
print(f"point-{i}: cx={x} cy={y}")
data, y_max = [120, 160, 90], 200
bar_w = int(cw / len(data) * 0.62)
for i, v in enumerate(data):
cx = round(origin_x + (i + 0.5) * cw / len(data))
y = round(origin_y - v / y_max * ch)
print(f"bar-{i}: x={cx - bar_w//2} y={y} w={bar_w} h={round(origin_y - y)}")
```
折线图:`x = origin_x + i/(n-1)*cw``y = origin_y - (v-y_min)/(y_max-y_min)*ch`
**装饰性元素(等间距范式)**
```python
@@ -163,9 +160,9 @@ for i in range(n):
```python
# 每个元素登记 (x, y, w, h),含 stroke 外扩
elements = [
(10, 20, 80, 160), # item-0
(107, 10, 80, 170), # item-1
(204, 40, 80, 140), # item-2
(10, 20, 80, 160), # bar-0
(107, 10, 80, 170), # bar-1
(204, 40, 80, 140), # bar-2
(0, 0, 300, 1), # x-axis
]
@@ -264,6 +261,7 @@ print(f"whiteboard width={wb_w} height={wb_h}")
| 流程图 | `flowchart TD` / `flowchart LR` | 业务流程、决策树、工作流 |
| 时序图 | `sequenceDiagram` | 系统交互、API 调用链 |
| 甘特图 | `gantt` | 项目计划、里程碑 |
| 饼图 | `pie` | 占比数据 |
| 类图 | `classDiagram` | 对象关系、架构设计 |
| ER 图 | `erDiagram` | 数据库结构 |
| 状态图 | `stateDiagram-v2` | 状态机、生命周期 |
@@ -281,6 +279,7 @@ Mermaid 图表会自动撑满 whiteboard 区域。建议:
|---------|-----------|------------|
| 流程图5-8 节点) | 720-816 | 300-400 |
| 时序图3-5 参与者) | 720-816 | 320-420 |
| 饼图 | 500-600 | 300-360 |
| 甘特图 | 816 | 280-360 |
| 思维导图 | 816 | 380-480 |
@@ -308,7 +307,7 @@ Mermaid 语法包含 `[`、`>`、`-->`,不用 CDATA 直接写会破坏 XML 解
- [ ] 文字 `y` 坐标为 baseline 位置,最小值 ≥ font-size避免被裁切
**SVG 模式——视觉品质检查:**
- [ ] 非原生数据视觉有必要的坐标轴、网格线、数值标注或分段说明,没有"裸点"或无解释色块
- [ ] 坐标轴、网格线、数值标注齐全,没有"裸柱子"或"裸折线"
- [ ] 字号有层级:标题 > 数值 > 轴标签,非全部相同
- [ ] 单一数据系列用同一颜色,多系列用不同颜色且对比充足
- [ ] 轴标签与图表元素互不遮挡,留有足够空间

View File

@@ -119,34 +119,6 @@ Each slide must include:
- `text_density`: `low`, `medium`, or `high`.
- `speaker_intent`: why the speaker needs this page and how it advances the story.
Optional slide fields:
- `chart_contract`: required when the page plan includes a standard data chart that `<chart>` supports. Use this shape:
```json
{
"chart_contract": {
"required": true,
"render_as": "native_chart",
"chart_type": "line",
"data_source": "mock_placeholder",
"data_series_required": true,
"placeholder_label_required": true,
"manual_shape_fallback_allowed": false
}
}
```
When `chart_contract.required == true`, XML generation must produce a `<chart>` element on that slide. A shape, line, polyline, or whiteboard approximation does not satisfy the plan.
`data_source` must be one of:
- `user_provided`: the user supplied concrete values, tables, CSV, or metric lists; use them and do not replace them with mock data.
- `mock_placeholder`: the user asked for a placeholder, template, example, or later-replaceable chart position; use mock data in native `<chart>`.
- `mock_required_by_intent`: the user did not provide concrete values but asked for data expression, charts, trends, comparisons, or distributions; use mock data in native `<chart>`.
`data_series_required` means the generated XML must include `<chartData>`. It does not require user-provided real-world values. When real values are unavailable but chart expression is part of the user's intent, write mock or placeholder values into native `<chart>` and label them clearly instead of switching to manual drawing primitives or metric blocks.
## Layout Vocabulary
Use one of these `layout_type` values unless the user explicitly needs a custom structure:
@@ -211,7 +183,6 @@ Use an object for one planned asset, an array for multiple real needs, or `asset
- `purpose`: why this asset helps the page's key message.
- `suggested_query`: short future lookup hint only; do not execute it unless separately requested.
- `fallback_if_missing`: concrete XML-native visual plan using shapes, labels, tables, whiteboard diagrams, or placeholder panels.
- `chart_contract`: when `asset_type` is `chart` and the visual is a supported standard data chart, set this optional slide-level field so generation is locked to native `<chart>`.
For detailed rules and examples, read `asset-planning.md`.
@@ -219,7 +190,7 @@ Good examples:
- `{"asset_type":"architecture_diagram","purpose":"Explain component relationships.","suggested_query":"service architecture diagram","fallback_if_missing":"Draw a component diagram with grouped boxes, connector arrows, and short labels."}`
- `{"asset_type":"logo","purpose":"Identify the customer context.","suggested_query":"customer logo","fallback_if_missing":"Use a text label in a small badge."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Render a native `<chart>` using the provided series when available; otherwise render a native `<chart>` with mock placeholder values and label it as 模拟数据,仅占位,待替换真实数据."}`
- `{"asset_type":"chart","purpose":"Show adoption trend.","suggested_query":"monthly adoption trend chart","fallback_if_missing":"Draw a simple trend line chart with axis labels and data points."}`
## XML Generation Contract
@@ -230,7 +201,6 @@ Before writing each slide XML, map the plan fields to concrete decisions:
- `visual_focus` determines the largest visual region or emphasized object.
- `text_density` caps visible text volume.
- `asset_need` informs placeholder diagrams, icons, charts, screenshots, or shape-based fallback visuals only. Missing real assets must use `fallback_if_missing`, not blank regions.
- `chart_contract` locks supported standard data charts to native `<chart>` output. Manual approximations are allowed only when the planned chart type is unsupported by `<chart>` or when the visual is explicitly non-data/decorative.
After creating the PPT, fetch the presentation and verify:

File diff suppressed because one or more lines are too long

View File

@@ -3018,7 +3018,7 @@
子元素(mermaid 与 svg 二选一):
- mermaid: Mermaid 源码文本, 可使用 CDATA 包裹
适用场景: 流程图、时序图、思维导图、类图、甘特图、ER 图、用户旅程等结构图
适用场景: 流程图、时序图、思维导图、类图、甘特图、饼图等结构化图表
特点: 用简短的文本声明描述图表逻辑, 由渲染引擎自动布局, 无需手动计算坐标
示例: &lt;mermaid&gt;&lt;![CDATA[flowchart TD\n A[开始] --&gt; B[结束]]]&gt;&lt;/mermaid&gt;
- svg: SVG 内容

View File

@@ -263,56 +263,7 @@
- `<chartLegend>`
- `<chartTooltip>`
完整图表类型覆盖示例见 [slides_chart_demo.xml](slides_chart_demo.xml),其中包含柱状、条形、折线、面积、饼 / 环、雷达等原生 `<chart>` 示例,以及散点、气泡、漏斗、帕累托、瀑布等 `<whiteboard>` SVG 图表示例
组合图示例(来自 [slides_chart_demo.xml](slides_chart_demo.xml)
```xml
<chart width="556" height="350" topLeftX="42" topLeftY="132">
<chartPlotArea>
<chartPlot type="combo">
<chartExtra/>
<chartSeriesList>
<chartSeries index="1" comboType="column"/>
<chartSeries index="2" comboType="line" yAxisPosition="right">
<chartTooltip format="0%"/>
</chartSeries>
</chartSeriesList>
</chartPlot>
<chartAxes>
<chartAxis type="x">
<chartLabel fontSize="10"/>
</chartAxis>
<chartAxis type="y" position="left">
<chartGridLine color="rgb(226, 232, 240)"/>
<chartLabel fontSize="10"/>
</chartAxis>
<chartAxis type="y" position="right">
<chartLabel fontSize="10" format="0%"/>
</chartAxis>
</chartAxes>
</chartPlotArea>
<chartLegend position="bottom" fontSize="11"/>
<chartData>
<dim1>
<chartField name="季度">24Q1,24Q2,24Q3,24Q4,25Q1,25Q2,25Q3,25Q4</chartField>
</dim1>
<dim2>
<chartField name="营收">180,195,210,245,220,238,258,296</chartField>
<chartField name="增速">0.08,0.12,0.15,0.18,0.22,0.22,0.23,0.21</chartField>
</dim2>
</chartData>
<chartTitle fontSize="12" color="rgba(15, 30, 58, 1)" bold="true">营收(亿美元, 左轴) · 同比增速(%, 右轴)</chartTitle>
<chartStyle>
<chartBackground color="rgba(0, 0, 0, 0)"/>
<chartBorder color="rgb(222, 224, 227)" width="0"/>
<chartColorTheme>
<color value="rgb(28, 71, 120)"/>
<color value="rgb(240, 129, 54)"/>
</chartColorTheme>
</chartStyle>
</chart>
```
如果要写图表 XML建议直接以 XSD 为准,不要自行发明更简化的 chart DSL
## 样式元素

View File

@@ -147,7 +147,7 @@ XSD 中的 `title`、`headline`、`sub-headline`、`body`、`caption` 主要出
### whiteboard
```xml
<!-- SVG 模式:<chart> 不支持的图表或自定义视觉、装饰元素 -->
<!-- SVG 模式:数据图表、装饰元素 -->
<whiteboard topLeftX="580" topLeftY="120" width="340" height="280">
<svg xmlns="http://www.w3.org/2000/svg">
<rect x="60" y="80" width="40" height="140" rx="3" fill="rgba(59,130,246,0.85)"/>

View File

@@ -24,7 +24,7 @@ metadata:
## 快速决策
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow-knowledge-organize.md`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md)该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
- URL`.../wiki/<token>``lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`

View File

@@ -1,9 +1,9 @@
# Drive CLI E2E Coverage
## Metrics
- Denominator: 32 leaf commands
- Covered: 11
- Coverage: 34.4%
- Denominator: 31 leaf commands
- Covered: 10
- Coverage: 32.3%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
@@ -12,8 +12,7 @@
- TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`.
- TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API.
- TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields.
- TestDriveListCommentsDryRun_DocxDefaults / TestDriveListCommentsDryRun_WikiToken: dry-run coverage for `drive +list-comments`; asserts URL parsing to `files/:token/comments`, default `is_solved=false`, default omitted `is_whole`, `user_id_type=open_id`, and Wiki token orchestration (`get_node -> comments.list`) without live API calls.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
@@ -27,7 +26,6 @@
| Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason |
| --- | --- | --- | --- | --- | --- |
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size`; `--user-id-type` | dry-run locks URL/token parsing, default unresolved filter, omitted all-scope filter, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| ✕ | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |

View File

@@ -81,41 +81,4 @@ func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) {
if got := gjson.Get(commentResult.Stdout, "data.file_extension").String(); got != ".md" {
t.Fatalf("data.file_extension=%q, want .md\nstdout:\n%s", got, commentResult.Stdout)
}
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"drive", "+list-comments",
"--token", fileToken,
"--type", "file",
"--solved-status", "all",
"--page-size", "100",
},
DefaultAs: "bot",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || !driveCommentListContainsID(result.Stdout, commentID)
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
if got := gjson.Get(listResult.Stdout, "data.file_token").String(); got != fileToken {
t.Fatalf("list data.file_token=%q, want %q\nstdout:\n%s", got, fileToken, listResult.Stdout)
}
if got := gjson.Get(listResult.Stdout, "data.file_type").String(); got != "file" {
t.Fatalf("list data.file_type=%q, want file\nstdout:\n%s", got, listResult.Stdout)
}
if !driveCommentListContainsID(listResult.Stdout, commentID) {
t.Fatalf("list comments did not include comment_id %q\nstdout:\n%s", commentID, listResult.Stdout)
}
}
func driveCommentListContainsID(stdout, commentID string) bool {
for _, item := range gjson.Get(stdout, "data.items").Array() {
if item.Get("comment_id").String() == commentID {
return true
}
}
return false
}

View File

@@ -1,100 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDriveListCommentsDryRun_DocxDefaults(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+list-comments",
"--url", "https://example.larksuite.com/docx/docxDryRunCommentList",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCommentList/comments" {
t.Fatalf("api.0.url=%q, want comments list\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.file_type").String(); got != "docx" {
t.Fatalf("api.0.params.file_type=%q, want docx\nstdout:\n%s", got, out)
}
isSolved := gjson.Get(out, "api.0.params.is_solved")
if !isSolved.Exists() || isSolved.Bool() {
t.Fatalf("api.0.params.is_solved=%v, want explicit false\nstdout:\n%s", isSolved.Value(), out)
}
if gjson.Get(out, "api.0.params.is_whole").Exists() {
t.Fatalf("api.0.params.is_whole should be omitted by default\nstdout:\n%s", out)
}
if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 {
t.Fatalf("api.0.params.page_size=%d, want 50\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != "open_id" {
t.Fatalf("api.0.params.user_id_type=%q, want open_id\nstdout:\n%s", got, out)
}
}
func TestDriveListCommentsDryRun_WikiToken(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+list-comments",
"--token", "wikiDryRunCommentList",
"--type", "wiki",
"--solved-status", "all",
"--comment-scope", "partial",
"--need-relation",
"--page-size", "99",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" {
t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunCommentList" {
t.Fatalf("api.0.params.token=%q, want wikiDryRunCommentList\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/<obj_token from step 1>/comments" {
t.Fatalf("api.1.url=%q, want resolved comments list placeholder\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "api.1.params.file_type").String(); got != "<obj_type from step 1>" {
t.Fatalf("api.1.params.file_type=%q, want obj_type placeholder\nstdout:\n%s", got, out)
}
if gjson.Get(out, "api.1.params.is_solved").Exists() {
t.Fatalf("api.1.params.is_solved should be omitted for solved-status all\nstdout:\n%s", out)
}
isWhole := gjson.Get(out, "api.1.params.is_whole")
if !isWhole.Exists() || isWhole.Bool() {
t.Fatalf("api.1.params.is_whole=%v, want explicit false for partial\nstdout:\n%s", isWhole.Value(), out)
}
if got := gjson.Get(out, "api.1.params.need_relation").String(); got != "<sent only when obj_type is docx>" {
t.Fatalf("api.1.params.need_relation=%q, want conditional placeholder\nstdout:\n%s", got, out)
}
}