From 70cc9ff13a3923659c492e272d1917b6d49a9316 Mon Sep 17 00:00:00 2001 From: wangweiming Date: Tue, 14 Apr 2026 18:24:57 +0800 Subject: [PATCH] feat: add drive create-folder shortcut and wiki node auto-grant Change-Id: I1acd001a1d4616bc5a957cad437e5aa4f1afeb51 --- shortcuts/drive/drive_create_folder.go | 120 ++++++++ shortcuts/drive/drive_create_folder_test.go | 266 ++++++++++++++++++ shortcuts/drive/shortcuts.go | 1 + shortcuts/drive/shortcuts_test.go | 1 + shortcuts/wiki/wiki_node_create.go | 23 +- shortcuts/wiki/wiki_node_create_test.go | 156 ++++++++++ skill-template/domains/drive.md | 1 + skills/lark-drive/SKILL.md | 2 + .../references/lark-drive-create-folder.md | 73 +++++ skills/lark-wiki/SKILL.md | 1 + .../references/lark-wiki-node-create.md | 26 ++ 11 files changed, 668 insertions(+), 2 deletions(-) create mode 100644 shortcuts/drive/drive_create_folder.go create mode 100644 shortcuts/drive/drive_create_folder_test.go create mode 100644 skills/lark-drive/references/lark-drive-create-folder.md diff --git a/shortcuts/drive/drive_create_folder.go b/shortcuts/drive/drive_create_folder.go new file mode 100644 index 000000000..116f147b7 --- /dev/null +++ b/shortcuts/drive/drive_create_folder.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +type driveCreateFolderSpec struct { + Name string + FolderToken string +} + +func newDriveCreateFolderSpec(runtime *common.RuntimeContext) driveCreateFolderSpec { + return driveCreateFolderSpec{ + Name: strings.TrimSpace(runtime.Str("name")), + FolderToken: strings.TrimSpace(runtime.Str("folder-token")), + } +} + +func (s driveCreateFolderSpec) RequestBody() map[string]interface{} { + return map[string]interface{}{ + "name": s.Name, + "folder_token": s.FolderToken, + } +} + +// DriveCreateFolder creates a new Drive folder under the specified parent +// folder, or under the caller's root folder when --folder-token is omitted. +var DriveCreateFolder = common.Shortcut{ + Service: "drive", + Command: "+create-folder", + Description: "Create a folder in Drive", + Risk: "write", + Scopes: []string{"space:folder:create"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "name", Desc: "folder name", Required: true}, + {Name: "folder-token", Desc: "parent folder token (default: root folder)"}, + }, + Tips: []string{ + "Omit --folder-token to create the folder in the caller's root folder.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateDriveCreateFolderSpec(newDriveCreateFolderSpec(runtime)) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := newDriveCreateFolderSpec(runtime) + dry := common.NewDryRunAPI(). + Desc("Create a folder in Drive"). + POST("/open-apis/drive/v1/files/create_folder"). + Desc("[1] Create folder"). + Body(spec.RequestBody()) + if runtime.IsBot() { + dry.Desc("After folder creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new folder.") + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := newDriveCreateFolderSpec(runtime) + + target := "root folder" + if spec.FolderToken != "" { + target = "folder " + common.MaskToken(spec.FolderToken) + } + fmt.Fprintf(runtime.IO().ErrOut, "Creating folder %q in %s...\n", spec.Name, target) + + data, err := runtime.CallAPI( + "POST", + "/open-apis/drive/v1/files/create_folder", + nil, + spec.RequestBody(), + ) + if err != nil { + return err + } + + folderToken := common.GetString(data, "token") + if folderToken == "" { + return output.Errorf(output.ExitAPI, "api_error", "drive create_folder succeeded but returned no folder token (data.token)") + } + out := map[string]interface{}{ + "created": true, + "name": spec.Name, + "folder_token": folderToken, + "parent_folder_token": spec.FolderToken, + } + if url := common.GetString(data, "url"); url != "" { + out["url"] = url + } + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, folderToken, "folder"); grant != nil { + out["permission_grant"] = grant + } + + runtime.Out(out, nil) + return nil + }, +} + +func validateDriveCreateFolderSpec(spec driveCreateFolderSpec) error { + if spec.Name == "" { + return output.ErrValidation("--name must not be empty") + } + if nameBytes := len([]byte(spec.Name)); nameBytes > 256 { + return output.ErrValidation("--name exceeds the maximum of 256 bytes (got %d)", nameBytes) + } + if spec.FolderToken != "" { + if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { + return output.ErrValidation("%s", err) + } + } + return nil +} diff --git a/shortcuts/drive/drive_create_folder_test.go b/shortcuts/drive/drive_create_folder_test.go new file mode 100644 index 000000000..2a531bb1d --- /dev/null +++ b/shortcuts/drive/drive_create_folder_test.go @@ -0,0 +1,266 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestValidateDriveCreateFolderSpecRejectsInvalidInputs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec driveCreateFolderSpec + wantErr string + }{ + { + name: "empty name", + spec: driveCreateFolderSpec{}, + wantErr: "--name must not be empty", + }, + { + name: "name too long", + spec: driveCreateFolderSpec{ + Name: strings.Repeat("a", 257), + }, + wantErr: "maximum of 256 bytes", + }, + { + name: "invalid folder token", + spec: driveCreateFolderSpec{ + Name: "Reports", + FolderToken: "../bad", + }, + wantErr: "--folder-token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateDriveCreateFolderSpec(tt.spec) + if err == nil { + t.Fatal("expected validation error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + }) + } +} + +func TestDriveCreateFolderDryRunIncludesCreateRequest(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{Use: "drive +create-folder"} + cmd.Flags().String("name", "", "") + cmd.Flags().String("folder-token", "", "") + if err := cmd.Flags().Set("name", " Weekly Reports "); err != nil { + t.Fatalf("set --name: %v", err) + } + if err := cmd.Flags().Set("folder-token", " fld_parent "); err != nil { + t.Fatalf("set --folder-token: %v", err) + } + + runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, core.AsBot) + dry := DriveCreateFolder.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal dry run: %v", err) + } + + var got struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal dry run json: %v", err) + } + if len(got.API) != 1 { + t.Fatalf("expected 1 API call, got %d", len(got.API)) + } + if got.API[0].Method != "POST" || got.API[0].URL != "/open-apis/drive/v1/files/create_folder" { + t.Fatalf("unexpected dry-run API call: %#v", got.API[0]) + } + if got.API[0].Body["name"] != "Weekly Reports" { + t.Fatalf("name = %#v, want %q", got.API[0].Body["name"], "Weekly Reports") + } + if got.API[0].Body["folder_token"] != "fld_parent" { + t.Fatalf("folder_token = %#v, want %q", got.API[0].Body["folder_token"], "fld_parent") + } +} + +func TestDriveCreateFolderBotAutoGrantSuccess(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user")) + + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "token": "fld_created", + "url": "https://example.feishu.cn/drive/folder/fld_created", + }, + }, + } + reg.Register(createStub) + + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/fld_created/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + } + reg.Register(permStub) + + err := mountAndRunDrive(t, DriveCreateFolder, []string{ + "+create-folder", + "--name", " Weekly Reports ", + "--folder-token", " fld_parent ", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedJSONBody(t, createStub) + if body["name"] != "Weekly Reports" { + t.Fatalf("name = %#v, want %q", body["name"], "Weekly Reports") + } + if body["folder_token"] != "fld_parent" { + t.Fatalf("folder_token = %#v, want %q", body["folder_token"], "fld_parent") + } + + data := decodeDriveEnvelope(t, stdout) + if data["folder_token"] != "fld_created" { + t.Fatalf("folder_token = %#v, want %q", data["folder_token"], "fld_created") + } + if data["parent_folder_token"] != "fld_parent" { + t.Fatalf("parent_folder_token = %#v, want %q", data["parent_folder_token"], "fld_parent") + } + if data["name"] != "Weekly Reports" { + t.Fatalf("name = %#v, want %q", data["name"], "Weekly Reports") + } + if data["url"] != "https://example.feishu.cn/drive/folder/fld_created" { + t.Fatalf("url = %#v, want folder url", data["url"]) + } + + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantGranted { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted) + } + if grant["user_open_id"] != "ou_current_user" { + t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user") + } + if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new folder." { + t.Fatalf("permission_grant.message = %#v", grant["message"]) + } + + permBody := decodeCapturedJSONBody(t, permStub) + if permBody["member_type"] != "openid" || permBody["member_id"] != "ou_current_user" || permBody["perm"] != "full_access" || permBody["type"] != "user" { + t.Fatalf("unexpected permission request body: %#v", permBody) + } +} + +func TestDriveCreateFolderUsesRootWhenParentIsOmitted(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user")) + + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "token": "fld_root_child", + }, + }, + } + reg.Register(createStub) + + err := mountAndRunDrive(t, DriveCreateFolder, []string{ + "+create-folder", + "--name", "Inbox", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedJSONBody(t, createStub) + if body["folder_token"] != "" { + t.Fatalf("folder_token = %#v, want empty string for root create", body["folder_token"]) + } + + data := decodeDriveEnvelope(t, stdout) + if data["folder_token"] != "fld_root_child" { + t.Fatalf("folder_token = %#v, want %q", data["folder_token"], "fld_root_child") + } + if data["parent_folder_token"] != "" { + t.Fatalf("parent_folder_token = %#v, want empty string", data["parent_folder_token"]) + } + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant in user mode output: %#v", data) + } +} + +func TestDriveCreateFolderRejectsCreateResponseWithoutToken(t *testing.T) { + t.Parallel() + + f, stdout, _, reg := cmdutil.TestFactory(t, drivePermissionGrantTestConfig(t, "ou_current_user")) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "url": "https://example.feishu.cn/drive/folder/unknown", + }, + }, + }) + + err := mountAndRunDrive(t, DriveCreateFolder, []string{ + "+create-folder", + "--name", "Broken Folder", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "returned no folder token") { + t.Fatalf("err = %v, want missing folder token error", err) + } + if stdout.Len() != 0 { + t.Fatalf("stdout should be empty on error, got %s", stdout.String()) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 1302f1f60..85d4821d4 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common" func Shortcuts() []common.Shortcut { return []common.Shortcut{ DriveUpload, + DriveCreateFolder, DriveCreateShortcut, DriveDownload, DriveAddComment, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index cfdbc9288..e8699735b 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -12,6 +12,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { got := Shortcuts() want := []string{ "+upload", + "+create-folder", "+create-shortcut", "+download", "+add-comment", diff --git a/shortcuts/wiki/wiki_node_create.go b/shortcuts/wiki/wiki_node_create.go index 5a14bcba2..2c5c67ad4 100644 --- a/shortcuts/wiki/wiki_node_create.go +++ b/shortcuts/wiki/wiki_node_create.go @@ -58,7 +58,11 @@ var WikiNodeCreate = common.Shortcut{ return validateWikiNodeCreateSpec(readWikiNodeCreateSpec(runtime), runtime.As()) }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - return buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime)) + dry := buildWikiNodeCreateDryRun(readWikiNodeCreateSpec(runtime)) + if runtime.IsBot() { + dry.Desc("After wiki node creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new wiki node.") + } + return dry }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { spec := readWikiNodeCreateSpec(runtime) @@ -70,7 +74,7 @@ var WikiNodeCreate = common.Shortcut{ } fmt.Fprintf(runtime.IO().ErrOut, "Created wiki node in space %s via %s.\n", execution.ResolvedSpace.SpaceID, execution.ResolvedSpace.ResolvedBy) - runtime.Out(wikiNodeCreateOutput(execution), nil) + runtime.Out(augmentWikiNodeCreateOutput(runtime, execution), nil) return nil }, } @@ -293,6 +297,9 @@ func runWikiNodeCreate(ctx context.Context, client wikiNodeCreateClient, identit if err != nil { return nil, err } + if node == nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "wiki node create returned no node") + } return &wikiNodeCreateExecution{ Node: node, @@ -462,3 +469,15 @@ func wikiNodeCreateOutput(execution *wikiNodeCreateExecution) map[string]interfa "has_child": node.HasChild, } } + +func augmentWikiNodeCreateOutput(runtime *common.RuntimeContext, execution *wikiNodeCreateExecution) map[string]interface{} { + if execution == nil || execution.Node == nil { + return map[string]interface{}{} + } + + out := wikiNodeCreateOutput(execution) + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, execution.Node.NodeToken, "wiki"); grant != nil { + out["permission_grant"] = grant + } + return out +} diff --git a/shortcuts/wiki/wiki_node_create_test.go b/shortcuts/wiki/wiki_node_create_test.go index 7fd184d6f..604d62e6f 100644 --- a/shortcuts/wiki/wiki_node_create_test.go +++ b/shortcuts/wiki/wiki_node_create_test.go @@ -29,6 +29,7 @@ type fakeWikiNodeCreateClient struct { spaces map[string]*wikiSpaceRecord nodes map[string]*wikiNodeRecord createNode *wikiNodeRecord + returnNilNode bool createErr error getSpaceErr error getNodeErr error @@ -65,6 +66,9 @@ func (fake *fakeWikiNodeCreateClient) CreateNode(ctx context.Context, spaceID st if fake.createErr != nil { return nil, fake.createErr } + if fake.returnNilNode { + return nil, nil + } if fake.createNode != nil { return fake.createNode, nil } @@ -81,6 +85,15 @@ func wikiTestConfig() *core.CliConfig { } } +func wikiPermissionTestConfig(userOpenID string) *core.CliConfig { + return &core.CliConfig{ + AppID: fmt.Sprintf("wiki-permission-test-app-%d", wikiTestConfigSeq.Add(1)), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: userOpenID, + } +} + func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, factory *cmdutil.Factory, stdout *bytes.Buffer) error { t.Helper() parent := &cobra.Command{Use: "wiki"} @@ -268,6 +281,26 @@ func TestRunWikiNodeCreateCreatesNodeInResolvedSpace(t *testing.T) { } } +func TestRunWikiNodeCreateRejectsNilCreatedNode(t *testing.T) { + t.Parallel() + + client := &fakeWikiNodeCreateClient{ + spaces: map[string]*wikiSpaceRecord{ + wikiMyLibrarySpaceID: {SpaceID: "space_my_library", SpaceType: "my_library"}, + }, + returnNilNode: true, + } + + _, err := runWikiNodeCreate(context.Background(), client, core.AsUser, wikiNodeCreateSpec{ + NodeType: wikiNodeTypeOrigin, + ObjType: "docx", + Title: "Roadmap", + }) + if err == nil || !strings.Contains(err.Error(), "wiki node create returned no node") { + t.Fatalf("expected missing node error, got %v", err) + } +} + func TestWikiNodeCreateDryRunShowsMyLibraryLookup(t *testing.T) { t.Parallel() @@ -484,3 +517,126 @@ func TestWikiNodeCreateMountedExecuteWithExplicitSpaceID(t *testing.T) { t.Fatalf("stderr = %q, want completed creation message", got) } } + +func TestWikiNodeCreateBotAutoGrantSuccess(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig("ou_current_user")) + + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_123/nodes", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_123", + "node_token": "wik_created", + "obj_token": "docx_created", + "obj_type": "docx", + "node_type": "origin", + "title": "Wiki Node", + "has_child": false, + }, + }, + "msg": "success", + }, + } + reg.Register(createStub) + + permStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/wik_created/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + } + reg.Register(permStub) + + err := mountAndRunWiki(t, WikiNodeCreate, []string{ + "+node-create", + "--space-id", "space_123", + "--title", "Wiki Node", + "--as", "bot", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + data := decodeWikiEnvelope(t, stdout) + grant, _ := data["permission_grant"].(map[string]interface{}) + if grant["status"] != common.PermissionGrantGranted { + t.Fatalf("permission_grant.status = %#v, want %q", grant["status"], common.PermissionGrantGranted) + } + if grant["user_open_id"] != "ou_current_user" { + t.Fatalf("permission_grant.user_open_id = %#v, want %q", grant["user_open_id"], "ou_current_user") + } + if grant["message"] != "Granted the current CLI user full_access (可管理权限) on the new wiki node." { + t.Fatalf("permission_grant.message = %#v", grant["message"]) + } + + var body map[string]interface{} + if err := json.Unmarshal(permStub.CapturedBody, &body); err != nil { + t.Fatalf("unmarshal permission body: %v", err) + } + if body["member_type"] != "openid" || body["member_id"] != "ou_current_user" || body["perm"] != "full_access" || body["type"] != "user" { + t.Fatalf("unexpected permission request body: %#v", body) + } + if body["perm_type"] != "container" { + t.Fatalf("perm_type = %#v, want %q", body["perm_type"], "container") + } +} + +func TestWikiNodeCreateUserSkipsPermissionGrantAugmentation(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + factory, stdout, _, reg := cmdutil.TestFactory(t, wikiPermissionTestConfig("ou_current_user")) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/wiki/v2/spaces/space_123/nodes", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "space_id": "space_123", + "node_token": "wik_created", + "obj_token": "docx_created", + "obj_type": "docx", + "node_type": "origin", + "title": "Wiki Node", + "has_child": false, + }, + }, + "msg": "success", + }, + }) + + err := mountAndRunWiki(t, WikiNodeCreate, []string{ + "+node-create", + "--space-id", "space_123", + "--title", "Wiki Node", + "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("mountAndRunWiki() error = %v", err) + } + + data := decodeWikiEnvelope(t, stdout) + if _, ok := data["permission_grant"]; ok { + t.Fatalf("did not expect permission_grant in user mode output: %#v", data) + } +} + +func TestAugmentWikiNodeCreateOutputReturnsEmptyMapForNilInput(t *testing.T) { + t.Parallel() + + if got := augmentWikiNodeCreateOutput(nil, nil); len(got) != 0 { + t.Fatalf("augmentWikiNodeCreateOutput(nil, nil) = %#v, want empty map", got) + } + + if got := augmentWikiNodeCreateOutput(nil, &wikiNodeCreateExecution{}); len(got) != 0 { + t.Fatalf("augmentWikiNodeCreateOutput(nil, empty execution) = %#v, want empty map", got) + } +} diff --git a/skill-template/domains/drive.md b/skill-template/domains/drive.md index 027caf871..521e78f37 100644 --- a/skill-template/domains/drive.md +++ b/skill-template/domains/drive.md @@ -6,6 +6,7 @@ - 用户要把本地 `.xlsx` / `.csv` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。 - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。 - 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。 +- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。 - `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。 ## 修改标题 diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 2522df6cd..712031eca 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -19,6 +19,7 @@ metadata: - 用户要把本地 `.xlsx` / `.csv` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。 - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。 - 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。 +- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。 - `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。 ## 修改标题 @@ -196,6 +197,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | Shortcut | 说明 | |----------|------| | [`+upload`](references/lark-drive-upload.md) | Upload a local file to Drive | +| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support | | [`+download`](references/lark-drive-download.md) | Download a file from Drive to local | | [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder | | [`+add-comment`](references/lark-drive-add-comment.md) | Add a full-document comment, or a local comment to selected docx text (also supports wiki URL resolving to doc/docx) | diff --git a/skills/lark-drive/references/lark-drive-create-folder.md b/skills/lark-drive/references/lark-drive-create-folder.md new file mode 100644 index 000000000..cb67d34cf --- /dev/null +++ b/skills/lark-drive/references/lark-drive-create-folder.md @@ -0,0 +1,73 @@ +# drive +create-folder(创建云空间文件夹) + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +在飞书云空间中创建一个新文件夹。该 shortcut 对原生 `drive files create_folder` 做了一层更适合日常使用的封装:`--folder-token` 可省略,此时会在调用者根目录创建;如果使用 `--as bot`,创建成功后 CLI 会尝试把新文件夹的可管理权限自动授予当前 CLI 用户。 + +## 命令 + +```bash +# 在根目录创建文件夹 +lark-cli drive +create-folder \ + --name "周报归档" + +# 在指定父文件夹下创建子文件夹 +lark-cli drive +create-folder \ + --folder-token \ + --name "2026-W16" + +# 预览底层调用 +lark-cli drive +create-folder \ + --folder-token \ + --name "分析资料" \ + --dry-run +``` + +## 返回值 + +成功后会返回一个 JSON 对象,常见字段包括: + +- `folder_token`:新建文件夹 token,可直接用于后续 `drive +move`、`drive +upload` 等命令 +- `url`:新建文件夹链接(如果接口返回) +- `name`:文件夹名称 +- `parent_folder_token`:父文件夹 token;为空字符串表示创建在根目录 +- `permission_grant`(可选):仅 `--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限 + +> [!IMPORTANT] +> 如果文件夹是**以应用身份(bot)创建**的,如 `lark-cli drive +create-folder --as bot`,在创建成功后 CLI 会**尝试为当前 CLI 用户自动授予该文件夹的 `full_access`(可管理权限)**。 +> +> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果: +> - `status = granted`:当前 CLI 用户已获得该文件夹的可管理权限 +> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限 +> - `status = failed`:文件夹已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该文件夹 +> +> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限”。 +> +> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name` | 是 | 文件夹名称,不能为空,最长 256 字节 | +| `--folder-token` | 否 | 父文件夹 token;省略时表示在调用者根目录创建 | + +## 行为说明 + +- **根目录创建**:不传 `--folder-token` 时,shortcut 会向 API 显式传空字符串 `folder_token=""`,让后端按“根目录”语义创建 +- **bot 自动授权**:只有在 `--as bot` 时,结果才会额外带上 `permission_grant` +- **原生 API 仍可用**:如果用户明确要求按底层 API 字段调用,仍可继续使用 `lark-cli drive files create_folder` + +## 推荐场景 + +- 用户说“在云空间新建一个文件夹 / 目录”时,优先使用 `drive +create-folder` +- 用户给了父文件夹链接或 token,需要在其下继续分层建目录时,传 `--folder-token` +- 如果后续还要上传文件、移动文件、建子目录,优先复用返回值里的 `folder_token` + +> [!CAUTION] +> `drive +create-folder` 是**写入操作**,执行前必须确认用户意图。 + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-wiki/SKILL.md b/skills/lark-wiki/SKILL.md index 0322e803e..96a2b4bcb 100644 --- a/skills/lark-wiki/SKILL.md +++ b/skills/lark-wiki/SKILL.md @@ -21,6 +21,7 @@ metadata: ## 快速决策 - 用户给的是知识库 URL(`.../wiki/`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":""}'` 获取 `space_id`,后续成员接口统一使用 `space_id`。 +- 用户要在知识库中创建新节点,优先使用 `lark-cli wiki +node-create`。 - 用户说“给知识库添加成员/管理员”:先把目标解析成“用户 / 群 / 部门”三类之一,再决定 `member_type`,不要先调 `wiki members create` 再根据报错反推类型。 - 用户说“部门 + bot”:这是已知不支持路径。不要继续尝试 `wiki members create --as bot`;直接提示必须改成 `--as user`,或明确告知当前要求无法完成。 - 用户说“用户 / 群 + 添加成员”:先解析对应 ID,再执行 `wiki members create`。 diff --git a/skills/lark-wiki/references/lark-wiki-node-create.md b/skills/lark-wiki/references/lark-wiki-node-create.md index 6fd004436..963aefe71 100644 --- a/skills/lark-wiki/references/lark-wiki-node-create.md +++ b/skills/lark-wiki/references/lark-wiki-node-create.md @@ -45,6 +45,31 @@ lark-cli wiki +node-create \ --dry-run ``` +## 返回值 + +成功后会返回一个 JSON 对象,常见字段包括: + +- `resolved_space_id`:最终用于创建的真实知识空间 ID +- `resolved_by`:空间解析来源,可能是 `explicit_space_id`、`parent_node_token`、`my_library` +- `node_token`:新建知识库节点 token +- `obj_token`:节点关联对象 token +- `obj_type`:节点关联对象类型 +- `node_type`:节点类型 +- `title`:节点标题 +- `permission_grant`(可选):仅 `--as bot` 时返回,说明是否已自动为当前 CLI 用户授予可管理权限 + +> [!IMPORTANT] +> 如果节点是**以应用身份(bot)创建**的,如 `lark-cli wiki +node-create --as bot`,在创建成功后 CLI 会**尝试为当前 CLI 用户自动授予该知识库节点的 `full_access`(可管理权限)**。 +> +> 以应用身份创建时,结果里会额外返回 `permission_grant` 字段,明确说明授权结果: +> - `status = granted`:当前 CLI 用户已获得该知识库节点的可管理权限 +> - `status = skipped`:本地没有可用的当前用户 `open_id`,因此不会自动授权;可提示用户先完成 `lark-cli auth login`,再让 AI / agent 继续使用应用身份(bot)授予当前用户权限 +> - `status = failed`:节点已创建成功,但自动授权用户失败;会带上失败原因,并提示稍后重试或继续使用 bot 身份处理该节点 +> +> `permission_grant.perm = full_access` 表示该资源已授予“可管理权限” +> +> **不要擅自执行 owner 转移。** 如果用户需要把 owner 转给自己,必须单独确认。 + ## 参数 | 参数 | 必填 | 说明 | @@ -84,6 +109,7 @@ lark-cli wiki +node-create \ - 仅传 `--title`:会展示 `my_library` 解析 + 创建节点 两步调用 - 仅传 `--parent-node-token`:会展示“查询父节点 -> 创建节点”两步调用 - 同时需要 `my_library` 和父节点时:会展示三步调用链 +- **bot 自动授权**:若使用 `--as bot`,结果还会额外带上 `permission_grant`,用于说明是否已自动为当前 CLI 用户授予新建节点的可管理权限 - **输出结果**:成功后会返回 `resolved_space_id`、`resolved_by`、`node_token`、`obj_token`、`obj_type`、`node_type`、`title` 等字段,便于后续继续操作 ## 推荐场景