From 994f9ca01355a936e4402a295dd1721138a5af44 Mon Sep 17 00:00:00 2001 From: "jiaxing.04" Date: Tue, 28 Jul 2026 00:28:10 +0800 Subject: [PATCH 1/2] feat(drive): add +member-list shortcut Add a Drive shortcut for listing collaborators on documents, files, folders, and wiki nodes. Resolve supported resource URLs into typed permission requests, preserve raw API data for machine consumers, and keep invalid flag combinations on typed validation paths. Key features: - Infer resource type and token from supported Drive URLs while requiring --type for bare tokens - Validate optional member fields and wiki-only permission type filters - Provide pretty output, skill guidance, unit coverage, and dry-run/live E2E workflows - Read dry-run assertions from the standard data.api success envelope --- shortcuts/drive/drive_member_list.go | 325 +++++++++++++ shortcuts/drive/drive_member_list_test.go | 426 ++++++++++++++++++ shortcuts/drive/shortcuts.go | 1 + shortcuts/drive/shortcuts_test.go | 1 + skills/lark-drive/SKILL.md | 1 + .../references/lark-drive-member-list.md | 63 +++ .../references/lark-drive-secure-label.md | 2 +- ...workflow-permission-governance-commands.md | 22 +- ...rk-drive-workflow-permission-governance.md | 5 +- tests/cli_e2e/drive/drive_member_list_test.go | 165 +++++++ 10 files changed, 1003 insertions(+), 8 deletions(-) create mode 100644 shortcuts/drive/drive_member_list.go create mode 100644 shortcuts/drive/drive_member_list_test.go create mode 100644 skills/lark-drive/references/lark-drive-member-list.md create mode 100644 tests/cli_e2e/drive/drive_member_list_test.go diff --git a/shortcuts/drive/drive_member_list.go b/shortcuts/drive/drive_member_list.go new file mode 100644 index 0000000000..85c9e74a26 --- /dev/null +++ b/shortcuts/drive/drive_member_list.go @@ -0,0 +1,325 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "io" + "net/url" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +type driveMemberListSpec struct { + Token string + Type string + Fields string + PermType string +} + +var driveMemberListTypes = []string{ + "doc", "sheet", "file", "wiki", "bitable", "docx", + "mindnote", "minutes", "slides", "folder", +} + +var driveMemberListFields = []string{"name", "type", "avatar", "external_label"} +var driveMemberListPermTypes = []string{"container", "single_page"} + +var driveMemberListURLPathToType = []struct { + Prefix string + Type string +}{ + {"/drive/folder/", "folder"}, + {"/docx/", "docx"}, + {"/doc/", "doc"}, + {"/sheets/", "sheet"}, + {"/base/", "bitable"}, + {"/bitable/", "bitable"}, + {"/wiki/", "wiki"}, + {"/file/", "file"}, + {"/mindnotes/", "mindnote"}, + {"/slides/", "slides"}, + {"/minutes/", "minutes"}, +} + +func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) { + token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type")) + if err != nil { + return driveMemberListSpec{}, err + } + fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields")) + if err != nil { + return driveMemberListSpec{}, err + } + permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type")) + if err != nil { + return driveMemberListSpec{}, err + } + return driveMemberListSpec{ + Token: token, + Type: resourceType, + Fields: fields, + PermType: permType, + }, nil +} + +func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token") + } + + explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type") + if err != nil { + return "", "", err + } + + if strings.Contains(raw, "://") { + parsed, parseErr := url.Parse(raw) + if parseErr != nil || parsed.Hostname() == "" { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token") + } + ref, ok := parseDriveMemberListResourceURLPath(parsed.Path) + if !ok { + return "", "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type", + raw, + ).WithParam("--token") + } + if explicitType != "" && explicitType != ref.Type { + return "", "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--type %q conflicts with URL path type %q; remove --type or use a matching value", + explicitType, + ref.Type, + ).WithParam("--type") + } + if err := validate.ResourceName(ref.Token, "--token"); err != nil { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") + } + return ref.Token, ref.Type, nil + } + + if explicitType == "" { + return "", "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--type is required when --token is a bare token; accepted values: %s", + strings.Join(driveMemberListTypes, ", "), + ).WithParam("--type") + } + if err := validate.ResourceName(raw, "--token"); err != nil { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") + } + return raw, explicitType, nil +} + +func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) { + for _, mapping := range driveMemberListURLPathToType { + if !strings.HasPrefix(path, mapping.Prefix) { + continue + } + token := path[len(mapping.Prefix):] + token = strings.TrimRight(token, "/") + if idx := strings.IndexByte(token, '/'); idx >= 0 { + token = token[:idx] + } + token = strings.TrimSpace(token) + if token == "" { + return common.ResourceRef{}, false + } + return common.ResourceRef{Type: mapping.Type, Token: token}, true + } + return common.ResourceRef{}, false +} + +func normalizeDriveMemberListFields(raw string, changed bool) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + if changed { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields") + } + return "", nil + } + + parts := strings.Split(raw, ",") + fields := make([]string, 0, len(parts)) + seen := make(map[string]bool, len(parts)) + for _, part := range parts { + field := strings.ToLower(strings.TrimSpace(part)) + if field == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields") + } + if field == "*" { + if len(parts) != 1 { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields") + } + return "*", nil + } + if !driveMemberListFieldAllowed(field) { + return "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "invalid value %q for --fields, allowed: %s, *", + strings.TrimSpace(part), + strings.Join(driveMemberListFields, ", "), + ).WithParam("--fields") + } + if !seen[field] { + fields = append(fields, field) + seen[field] = true + } + } + return strings.Join(fields, ","), nil +} + +func driveMemberListFieldAllowed(field string) bool { + for _, allowed := range driveMemberListFields { + if field == allowed { + return true + } + } + return false +} + +func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) { + permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type") + if err != nil { + return "", err + } + if resourceType != "wiki" && changed { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type") + } + return permType, nil +} + +func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", nil + } + for _, candidate := range allowed { + if strings.EqualFold(value, candidate) { + return candidate, nil + } + } + return "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "invalid value %q for %s, allowed: %s", + value, + flagName, + strings.Join(allowed, ", "), + ).WithParam(flagName) +} + +func (s driveMemberListSpec) apiPath() string { + return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token)) +} + +func (s driveMemberListSpec) params() map[string]interface{} { + params := map[string]interface{}{"type": s.Type} + if s.Fields != "" { + params["fields"] = s.Fields + } + if s.PermType != "" { + params["perm_type"] = s.PermType + } + return params +} + +// DriveMemberList lists collaborator/member permissions on a Drive resource. +var DriveMemberList = common.Shortcut{ + Service: "drive", + Command: "+member-list", + Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node", + Risk: "read", + Scopes: []string{"docs:permission.member:retrieve"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true}, + {Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"}, + {Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"}, + {Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"}, + }, + Tips: []string{ + "--token accepts a Lark URL or bare token; pass --type when using a bare token.", + "Use --type folder for Drive folders.", + "--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.", + "--perm-type only applies to wiki nodes.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := readDriveMemberListSpec(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec, err := readDriveMemberListSpec(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + Desc("List Drive collaborator/member permissions"). + GET(spec.apiPath()). + Params(spec.params()) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec, err := readDriveMemberListSpec(runtime) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token)) + data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil) + if err != nil { + return err + } + if items, ok := data["items"].([]interface{}); ok { + fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items)) + } + runtime.OutFormat(data, nil, func(w io.Writer) { + renderDriveMemberListPretty(w, data) + }) + return nil + }, +} + +func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) { + items, _ := data["items"].([]interface{}) + if len(items) == 0 { + fmt.Fprintln(w, "No Drive members found.") + return + } + for i, raw := range items { + member, _ := raw.(map[string]interface{}) + fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"])) + fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"])) + fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"])) + if permType := driveMemberListValue(member["perm_type"]); permType != "-" { + fmt.Fprintf(w, " perm_type: %s\n", permType) + } + if memberType := driveMemberListValue(member["type"]); memberType != "-" { + fmt.Fprintf(w, " type: %s\n", memberType) + } + if name := driveMemberListValue(member["name"]); name != "-" { + fmt.Fprintf(w, " name: %s\n", name) + } + if avatar := driveMemberListValue(member["avatar"]); avatar != "-" { + fmt.Fprintf(w, " avatar: %s\n", avatar) + } + if label, ok := member["external_label"]; ok { + fmt.Fprintf(w, " external_label: %v\n", label) + } + fmt.Fprintln(w) + } +} + +func driveMemberListValue(v interface{}) string { + if s, ok := v.(string); ok && s != "" { + return s + } + return "-" +} diff --git a/shortcuts/drive/drive_member_list_test.go b/shortcuts/drive/drive_member_list_test.go new file mode 100644 index 0000000000..0beb91a7d5 --- /dev/null +++ b/shortcuts/drive/drive_member_list_test.go @@ -0,0 +1,426 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "drive +member-list"} + cmd.Flags().String("token", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().String("fields", "", "") + cmd.Flags().String("perm-type", "", "") + for name, value := range map[string]string{ + "token": token, + "type": docType, + "fields": fields, + "perm-type": permType, + } { + if value == "" { + continue + } + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return common.TestNewRuntimeContext(cmd, driveTestConfig()) +} + +func TestDriveMemberListSpecResolvesTargets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + docType string + wantTok string + wantType string + }{ + { + name: "folder URL", + token: "https://example.feishu.cn/drive/folder/fldTok?from=share", + wantTok: "fldTok", + wantType: "folder", + }, + { + name: "docx URL", + token: "https://example.feishu.cn/docx/doxTok", + wantTok: "doxTok", + wantType: "docx", + }, + { + name: "bare folder token", + token: " fldTok ", + docType: " folder ", + wantTok: "fldTok", + wantType: "folder", + }, + { + name: "mindnotes URL", + token: "https://example.feishu.cn/mindnotes/mndTok", + wantTok: "mndTok", + wantType: "mindnote", + }, + { + name: "minutes URL", + token: "https://example.feishu.cn/minutes/obTok", + wantTok: "obTok", + wantType: "minutes", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "") + spec, err := readDriveMemberListSpec(runtime) + if err != nil { + t.Fatalf("read spec: %v", err) + } + if spec.Token != tt.wantTok || spec.Type != tt.wantType { + t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType) + } + }) + } +} + +func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + docType string + fields string + permType string + wantParam string + wantMessage string + }{ + { + name: "missing token", + wantParam: "--token", + wantMessage: "--token is required", + }, + { + name: "bare token without type", + token: "doxTok", + wantParam: "--type", + wantMessage: "--type is required", + }, + { + name: "unsupported URL", + token: "https://example.feishu.cn/calendar/calTok", + wantParam: "--token", + wantMessage: "unsupported --token URL", + }, + { + name: "URL type conflict", + token: "https://example.feishu.cn/docx/doxTok", + docType: "folder", + wantParam: "--type", + wantMessage: "conflicts with URL path type", + }, + { + name: "invalid bare token", + token: "../bad", + docType: "folder", + wantParam: "--token", + wantMessage: "--token", + }, + { + name: "invalid type", + token: "doxTok", + docType: "comment", + wantParam: "--type", + wantMessage: "invalid value", + }, + { + name: "invalid fields", + token: "doxTok", + docType: "docx", + fields: "name,unknown", + wantParam: "--fields", + wantMessage: "invalid value", + }, + { + name: "star mixed with fields", + token: "doxTok", + docType: "docx", + fields: "*,name", + wantParam: "--fields", + wantMessage: "cannot be combined", + }, + { + name: "perm type rejected for non-wiki", + token: "doxTok", + docType: "docx", + permType: "single_page", + wantParam: "--perm-type", + wantMessage: "only applies when resource type is wiki", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType) + _, err := readDriveMemberListSpec(runtime) + if err == nil { + t.Fatal("expected validation error, got nil") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error is not typed: %T %v", err, err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype) + } + validationErr, ok := err.(*errs.ValidationError) + if !ok { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if validationErr.Param != tt.wantParam { + t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam) + } + if !strings.Contains(err.Error(), tt.wantMessage) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage) + } + }) + } +} + +func TestDriveMemberListSpecParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + docType string + fields string + permType string + want map[string]interface{} + }{ + { + name: "default omits optional params", + token: "doxTok", + docType: "docx", + want: map[string]interface{}{"type": "docx"}, + }, + { + name: "fields canonicalized and deduplicated", + token: "doxTok", + docType: "docx", + fields: "Name,avatar,name", + want: map[string]interface{}{"type": "docx", "fields": "name,avatar"}, + }, + { + name: "wiki accepts perm type", + token: "wikTok", + docType: "WIKI", + fields: "*", + permType: "SINGLE_PAGE", + want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"}, + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType) + spec, err := readDriveMemberListSpec(runtime) + if err != nil { + t.Fatalf("read spec: %v", err) + } + if got := spec.params(); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("params = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberList, []string{ + "+member-list", + "--token", "https://example.feishu.cn/drive/folder/fldTok", + "--fields", "*", + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + Data struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if len(got.Data.API) != 1 { + t.Fatalf("api count = %d, want 1", len(got.Data.API)) + } + api := got.Data.API[0] + if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" { + t.Fatalf("api = %#v", api) + } + if api.Params["type"] != "folder" || api.Params["fields"] != "*" { + t.Fatalf("params = %#v", api.Params) + } + if _, ok := api.Params["perm_type"]; ok { + t.Fatalf("perm_type should be omitted for folder: %#v", api.Params) + } +} + +func TestDriveMemberListExecutePreservesRawData(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig()) + + var capturedQuery string + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/permissions/doxTok/members", + OnMatch: func(req *http.Request) { + capturedQuery = req.URL.RawQuery + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "member_id": "ou_x", + "member_type": "openid", + "perm": "view", + "type": "user", + "name": "zhangsan", + "server_future": "preserved", + "external_label": true, + }, + }, + "server_top_level": "preserved", + }, + }, + }) + + err := mountAndRunDrive(t, DriveMemberList, []string{ + "+member-list", + "--token", "doxTok", + "--type", "docx", + "--fields", "name,type,external_label", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(capturedQuery, "type=docx") || + !strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") { + t.Fatalf("captured query = %q", capturedQuery) + } + data := decodeDriveEnvelope(t, stdout) + if data["server_top_level"] != "preserved" { + t.Fatalf("server_top_level = %#v", data["server_top_level"]) + } + for _, key := range []string{"token", "type", "count"} { + if _, ok := data[key]; ok { + t.Fatalf("data[%s] = %#v, want omitted", key, data[key]) + } + } + items, _ := data["items"].([]interface{}) + if len(items) != 1 { + t.Fatalf("items = %#v, want one item", data["items"]) + } + item, _ := items[0].(map[string]interface{}) + if item["server_future"] != "preserved" || item["external_label"] != true { + t.Fatalf("item future fields not preserved: %#v", item) + } + if !strings.Contains(stderr.String(), "Found 1 Drive member") { + t.Fatalf("stderr = %q, want count log", stderr.String()) + } +} + +func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) { + t.Parallel() + + if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) { + t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes) + } + if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) { + t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes) + } +} + +func TestDriveMemberListPrettyOutput(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/permissions/wikTok/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "member_id": "ou_x", + "member_type": "openid", + "perm": "view", + "perm_type": "single_page", + "type": "user", + "name": "zhangsan", + }, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveMemberList, []string{ + "+member-list", + "--token", "wikTok", + "--type", "wiki", + "--perm-type", "single_page", + "--format", "pretty", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} { + if !strings.Contains(out, want) { + t.Fatalf("pretty output missing %q:\n%s", want, out) + } + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index d274674c94..a61a3f459c 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -32,6 +32,7 @@ func Shortcuts() []common.Shortcut { DriveTaskResult, DriveApplyPermission, DriveMemberAdd, + DriveMemberList, DriveSecureLabelList, DriveSecureLabelUpdate, DriveSearch, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 5f5071d1a6..2c38cb3807 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -39,6 +39,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+task_result", "+apply-permission", "+member-add", + "+member-list", "+secure-label-list", "+secure-label-update", "+search", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 2be992fa18..43153a8656 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -163,6 +163,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 | | [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 | | [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 | +| [`+member-list`](references/lark-drive-member-list.md) | 查询 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 | | [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 | | [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 | diff --git a/skills/lark-drive/references/lark-drive-member-list.md b/skills/lark-drive/references/lark-drive-member-list.md new file mode 100644 index 0000000000..f31a547413 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-member-list.md @@ -0,0 +1,63 @@ +# drive +member-list(查询协作者/授权成员列表) + +本 skill 对应 shortcut:`lark-cli drive +member-list`。它读取 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 + +## 命令 + +```bash +# URL 自动推断 type +lark-cli drive +member-list \ + --token 'https://example.feishu.cn/drive/folder/' \ + --as user --format json + +# 查询附加字段 +lark-cli drive +member-list \ + --token '' \ + --type docx \ + --fields 'name,type,external_label' \ + --as user --format json + +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 | +| `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 | +| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 获取当前支持的所有附加字段。 | +| `--perm-type` | 否 | 仅 `--type wiki` 有效;取值 `container` / `single_page`。 | +| `--dry-run` | 否 | 只打印请求,不调用 API。 | + +## 输出 + +JSON 输出原样透传 API 的 `data` : + +```json +{ + "ok": true, + "identity": "user", + "data": { + "items": [ + { + "member_type": "openid", + "member_id": "ou_xxx", + "perm": "view", + "perm_type": "container", + "type": "user", + "name": "zhangsan", + "external_label": false + } + ] + } +} +``` + +`--format pretty` 会轻量展示成员 ID、成员类型、权限、wiki `perm_type` 和已返回的附加字段。机器读取优先使用 `--format json`。 + +## 行为说明 + +- **身份支持**:`--as user` 和 `--as bot` 均可用;缺 scope 或目标权限时按统一 permission 错误路径处理。 +- **所需 scope**:`docs:permission.member:retrieve`。 +- **fields 默认**:不传 `--fields` 时按官方 API 默认,不请求姓名、头像、外部标签等附加字段;需要时显式指定。 +- **folder 支持**:CLI 支持 `--type folder` 并会按需求发送 `type=folder`;部分环境的后端如果尚未放开 folder 枚举,可能返回 `99992402 field validation failed`。 diff --git a/skills/lark-drive/references/lark-drive-secure-label.md b/skills/lark-drive/references/lark-drive-secure-label.md index a8790d16c6..b713e5c521 100644 --- a/skills/lark-drive/references/lark-drive-secure-label.md +++ b/skills/lark-drive/references/lark-drive-secure-label.md @@ -28,7 +28,7 @@ lark-cli drive +secure-label-list --page-size 10 --lang zh ```bash lark-cli drive +secure-label-update \ --token "https://example.feishu.cn/docx/doxcnxxxx" \ - --label-id "7217780879644737539" + --label-id '' # replace $LABEL_ID before running ``` 参数: diff --git a/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md b/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md index b9caec04ad..4cb2e07eea 100644 --- a/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +++ b/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md @@ -25,16 +25,16 @@ lark-cli drive +inspect --url '' --as user --format json lark-cli wiki +node-list \ --space-id '' --page-size 50 \ --page-all --page-limit 0 \ - --as user --format json + --as user --format json # replace $SPACE_ID before running lark-cli wiki +node-list \ --space-id '' --parent-node-token '' --page-size 50 \ --page-all --page-limit 0 \ - --as user --format json + --as user --format json # replace $SPACE_ID before running lark-cli wiki +node-list \ --space-id '' --page-token '' --page-size 50 \ - --as user --format json + --as user --format json # replace $SPACE_ID before running ``` 解析返回时使用 `data.nodes`,不要读取顶层 `items`。`--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。 @@ -69,6 +69,18 @@ lark-cli drive permission.public get \ --as user --format json ``` +按需读取直接协作者/授权成员列表: + +```bash +lark-cli drive +member-list \ + --token '' \ + --type '' \ + --fields 'name,type,external_label' \ + --as user --format json +``` + +`--fields` 默认不传;只有需要名称、协作者类型、头像或外部标签时才显式传。该命令读取的是当前目标的直接协作者/授权成员列表,不代表完整继承链或历史权限变更审计。 + 按需读取访问统计: ```bash @@ -160,9 +172,9 @@ lark-cli drive +secure-label-list \ ```bash lark-cli drive +secure-label-update \ --token '' \ - --label-id '' --as user --format json + --label-id '' --as user --format json # replace $LABEL_ID before running lark-cli drive +secure-label-update \ --token '' --type '' \ - --label-id '' --as user --format json + --label-id '' --as user --format json # replace $LABEL_ID before running ``` diff --git a/skills/lark-drive/references/lark-drive-workflow-permission-governance.md b/skills/lark-drive/references/lark-drive-workflow-permission-governance.md index 82fd11760f..50d3fdf47d 100644 --- a/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +++ b/skills/lark-drive/references/lark-drive-workflow-permission-governance.md @@ -42,7 +42,7 @@ Risk / Structure: `R2` / `S2` - 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。 - 未按范围确认的批量写入。 -不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut。 +协作者列表读取只覆盖当前目标的直接协作者/授权成员:可使用 `drive +member-list` 。 ## Progressive Load Map @@ -96,6 +96,7 @@ Risk / Structure: `R2` / `S2` | `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 | | `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata | | `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 | +| `FACT_READ` | `drive +member-list` | 读取用户显式要求的单目标直接协作者/授权成员列表;不代表完整继承链或历史权限审计 | | `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 | | `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 | | `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs | @@ -194,7 +195,7 @@ Drive folder 发现: - `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke;未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。 - backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner,并走本 workflow 的 owner-transfer 确认。 - `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。 -- 当前 CLI 没有 `permission.members list`、完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。 +- `drive +member-list` 可读取单目标直接协作者/授权成员;当前 CLI 仍没有完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。 ## 输出策略 diff --git a/tests/cli_e2e/drive/drive_member_list_test.go b/tests/cli_e2e/drive/drive_member_list_test.go new file mode 100644 index 0000000000..9f1b8575ad --- /dev/null +++ b/tests/cli_e2e/drive/drive_member_list_test.go @@ -0,0 +1,165 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDrive_MemberListDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + wantURL string + wantType string + wantFields string + wantPermType string + }{ + { + name: "bare folder token", + args: []string{ + "drive", "+member-list", + "--token", "fldE2E001", + "--type", "folder", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/fldE2E001/members", + wantType: "folder", + }, + { + name: "folder URL infers folder type", + args: []string{ + "drive", "+member-list", + "--token", "https://example.feishu.cn/drive/folder/fldE2E002?from=share", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/fldE2E002/members", + wantType: "folder", + }, + { + name: "fields star is passed only when explicit", + args: []string{ + "drive", "+member-list", + "--token", "doxE2E003", + "--type", "docx", + "--fields", "*", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxE2E003/members", + wantType: "docx", + wantFields: "*", + }, + { + name: "wiki perm type", + args: []string{ + "drive", "+member-list", + "--token", "wikE2E004", + "--type", "wiki", + "--fields", "name,type", + "--perm-type", "single_page", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/wikE2E004/members", + wantType: "wiki", + wantFields: "name,type", + wantPermType: "single_page", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "data.api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "data.api.0.url").String(); got != tt.wantURL { + t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out) + } + if got := gjson.Get(out, "data.api.0.params.type").String(); got != tt.wantType { + t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out) + } + if tt.wantFields == "" { + if gjson.Get(out, "data.api.0.params.fields").Exists() { + t.Fatalf("params.fields should be omitted\nstdout:\n%s", out) + } + } else if got := gjson.Get(out, "data.api.0.params.fields").String(); got != tt.wantFields { + t.Fatalf("params.fields = %q, want %q\nstdout:\n%s", got, tt.wantFields, out) + } + if tt.wantPermType == "" { + if gjson.Get(out, "data.api.0.params.perm_type").Exists() { + t.Fatalf("params.perm_type should be omitted\nstdout:\n%s", out) + } + } else if got := gjson.Get(out, "data.api.0.params.perm_type").String(); got != tt.wantPermType { + t.Fatalf("params.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out) + } + }) + } +} + +func TestDrive_MemberListWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + folderName := "lark-cli-e2e-drive-member-list-" + clie2e.GenerateSuffix() + folderToken := createDriveFolderOrSkipPermission(t, parentT, ctx, folderName) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+member-list", + "--token", folderToken, + "--type", "folder", + "--format", "json", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr) + if strings.Contains(combinedOutput, "docs:permission.member:retrieve") || + strings.Contains(combinedOutput, "app scope not enabled") || + strings.Contains(combinedOutput, "missing required scope") || + strings.Contains(combinedOutput, "missing_scope") || + strings.Contains(combinedOutput, "99991672") || + strings.Contains(combinedOutput, "1063002") || + strings.Contains(combinedOutput, "1063004") || + strings.Contains(combinedOutput, "permission denied") || + strings.Contains(combinedOutput, "no share permission") { + t.Skipf("skip drive member list workflow due to missing bot scope or folder permission: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr)) + } + if strings.Contains(combinedOutput, "99992402") && + strings.Contains(combinedOutput, "field validation failed") { + t.Skipf("skip drive member list workflow because this environment does not yet accept type=folder on the member list API: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr)) + } + t.Fatalf("drive member list workflow failed: exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr) + } + result.AssertStdoutStatus(t, true) + + if items := gjson.Get(result.Stdout, "data.items"); !items.Exists() || !items.IsArray() { + t.Fatalf("data.items must be present as an array\nstdout:\n%s", result.Stdout) + } +} From 81d4cc6d4b85dfbf3a686a8f6c41464a4c2396e0 Mon Sep 17 00:00:00 2001 From: "jiaxing.04" Date: Wed, 29 Jul 2026 15:47:20 +0800 Subject: [PATCH 2/2] feat/drive-member-list --- skills/lark-drive/references/lark-drive-member-list.md | 6 ++++-- .../lark-drive-workflow-permission-governance-commands.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/skills/lark-drive/references/lark-drive-member-list.md b/skills/lark-drive/references/lark-drive-member-list.md index f31a547413..592e80155d 100644 --- a/skills/lark-drive/references/lark-drive-member-list.md +++ b/skills/lark-drive/references/lark-drive-member-list.md @@ -25,7 +25,7 @@ lark-cli drive +member-list \ |------|------|------| | `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 | | `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 | -| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 获取当前支持的所有附加字段。 | +| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 请求当前支持的所有附加字段。该参数只声明期望返回的字段,不授予字段级权限。 | | `--perm-type` | 否 | 仅 `--type wiki` 有效;取值 `container` / `single_page`。 | | `--dry-run` | 否 | 只打印请求,不调用 API。 | @@ -58,6 +58,8 @@ JSON 输出原样透传 API 的 `data` : ## 行为说明 - **身份支持**:`--as user` 和 `--as bot` 均可用;缺 scope 或目标权限时按统一 permission 错误路径处理。 -- **所需 scope**:`docs:permission.member:retrieve`。 +- **接口 scope**:查询成员列表需要 `docs:permission.member:retrieve`。 - **fields 默认**:不传 `--fields` 时按官方 API 默认,不请求姓名、头像、外部标签等附加字段;需要时显式指定。 +- **字段级权限**:`--fields` 只控制请求哪些附加字段,不保证服务端一定返回。请求用户的 `name` / `avatar` 时,应用还需开通 `contact:user.base:readonly`(“获取用户基本信息”;已具备官方兼容的历史通讯录权限也可满足要求)。 +- **缺字段语义**:字段级权限或数据可见性不足时,接口仍可能成功,但会省略相应敏感字段。响应中缺少已请求字段表示“服务端未返回”,不能解释为字段值为空,也不能据此认定成员信息完整。 - **folder 支持**:CLI 支持 `--type folder` 并会按需求发送 `type=folder`;部分环境的后端如果尚未放开 folder 枚举,可能返回 `99992402 field validation failed`。 diff --git a/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md b/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md index 4cb2e07eea..231e1ae170 100644 --- a/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +++ b/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md @@ -79,7 +79,7 @@ lark-cli drive +member-list \ --as user --format json ``` -`--fields` 默认不传;只有需要名称、协作者类型、头像或外部标签时才显式传。该命令读取的是当前目标的直接协作者/授权成员列表,不代表完整继承链或历史权限变更审计。 +`--fields` 默认不传;只有需要名称、协作者类型、头像或外部标签时才显式传。它只声明期望返回的字段,不授予字段级权限:请求用户的 `name` / `avatar` 时还需 `contact:user.base:readonly`(“获取用户基本信息”)。字段权限或数据可见性不足时,接口仍可能成功但省略相应字段;缺字段不能解释为空值。 按需读取访问统计: