diff --git a/shortcuts/base/base_block_create.go b/shortcuts/base/base_block_create.go new file mode 100644 index 00000000..40b3b23c --- /dev/null +++ b/shortcuts/base/base_block_create.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseBlockCreate = common.Shortcut{ + Service: "base", + Command: "+base-block-create", + Description: "Create a block", + Risk: "write", + Scopes: []string{"base:block:create"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "type", Desc: "resource type", Required: true, Enum: baseBlockTypeEnums}, + {Name: "name", Desc: "block name", Required: true}, + {Name: "parent-id", Desc: "folder block id; when omitted, create at root"}, + }, + Tips: []string{ + "Example: lark-cli base +base-block-create --base-token --type folder --name \"Project Docs\"", + "Example: lark-cli base +base-block-create --base-token --type table --name \"Tasks\"", + "Example: lark-cli base +base-block-create --base-token --type docx --name \"Spec\" --parent-id ", + "Example: lark-cli base +base-block-create --base-token --type dashboard --name \"Metrics\"", + "Example: lark-cli base +base-block-create --base-token --type workflow --name \"Approval Flow\"", + "Creates a folder, table, docx, dashboard, or workflow entry.", + "Do not pass null for --parent-id. Omit it to create at the root level.", + "Created resources still use their own commands for content operations, such as table/field/record/docx/dashboard/workflow commands.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateBaseBlockCreate(runtime) + }, + DryRun: dryRunBaseBlockCreate, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseBlockCreate(runtime) + }, +} diff --git a/shortcuts/base/base_block_delete.go b/shortcuts/base/base_block_delete.go new file mode 100644 index 00000000..3faf2843 --- /dev/null +++ b/shortcuts/base/base_block_delete.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseBlockDelete = common.Shortcut{ + Service: "base", + Command: "+base-block-delete", + Description: "Delete a block", + Risk: "high-risk-write", + Scopes: []string{"base:block:delete"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + baseBlockIDFlag(true), + }, + Tips: []string{ + "Example: lark-cli base +base-block-delete --base-token --block-id --yes", + "Deletes the block identified by --block-id.", + "Recursive folder deletion is not supported. If a folder is not empty, move or delete its children first.", + "Different block types may have independent backing resources; deletion follows backend semantics.", + "Use +base-block-list first when you need to confirm the target block id.", + "If the user already explicitly confirmed this exact delete target, pass --yes without asking again.", + }, + DryRun: dryRunBaseBlockDelete, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseBlockDelete(runtime) + }, +} diff --git a/shortcuts/base/base_block_list.go b/shortcuts/base/base_block_list.go new file mode 100644 index 00000000..c0862f09 --- /dev/null +++ b/shortcuts/base/base_block_list.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseBlockList = common.Shortcut{ + Service: "base", + Command: "+base-block-list", + Description: "List blocks in a base", + Risk: "read", + Scopes: []string{"base:block:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "type", Desc: "filter by resource type", Enum: baseBlockTypeEnums}, + {Name: "parent-id", Desc: "folder block id; when omitted, list all blocks"}, + }, + Tips: []string{ + "Example: lark-cli base +base-block-list --base-token ", + "Example: lark-cli base +base-block-list --base-token --type table", + "Example: lark-cli base +base-block-list --base-token --parent-id ", + `JQ crop: lark-cli base +base-block-list --base-token | jq '.blocks[] | {type, name, block_id: .id, parent_id}'`, + `JQ crop docx: lark-cli base +base-block-list --base-token --type docx | jq '.blocks[] | {name, docx_token}'`, + "Blocks are resources managed directly by the base, such as folder, table, docx, dashboard, and workflow.", + "For table, dashboard, and workflow blocks, returned id is the table-id, dashboard-id, or workflow-id used by the corresponding commands.", + "For docx blocks, use the returned docx_token with docx commands.", + "For folder blocks, pass the returned id as --parent-id when creating, listing, or moving blocks inside that folder.", + "This command returns the full backend list. It intentionally does not expose limit or offset.", + "Pass --type to list only one resource type.", + "Pass --parent-id to list only direct children of a folder.", + "Dashboard blocks are chart/widget blocks inside a dashboard; use +dashboard-block-* for those.", + }, + DryRun: dryRunBaseBlockList, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseBlockList(runtime) + }, +} diff --git a/shortcuts/base/base_block_move.go b/shortcuts/base/base_block_move.go new file mode 100644 index 00000000..1b1198d5 --- /dev/null +++ b/shortcuts/base/base_block_move.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseBlockMove = common.Shortcut{ + Service: "base", + Command: "+base-block-move", + Description: "Move a block", + Risk: "write", + Scopes: []string{"base:block:update"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + baseBlockIDFlag(true), + {Name: "parent-id", Desc: "target folder block id; when omitted, move to root"}, + {Name: "before-id", Desc: "sibling block id; move the block before this sibling in the target folder/root order"}, + {Name: "after-id", Desc: "sibling block id; move the block after this sibling in the target folder/root order"}, + }, + Tips: []string{ + "Example: lark-cli base +base-block-move --base-token --block-id --parent-id ", + "Example: lark-cli base +base-block-move --base-token --block-id --after-id ", + "Example: lark-cli base +base-block-move --base-token --block-id --before-id ", + "Example: lark-cli base +base-block-move --base-token --block-id ", + "Omit --parent-id to move the block to root; do not pass null.", + "--before-id and --after-id are mutually exclusive.", + "When moving a folder, its children remain under that folder.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateBaseBlockMove(runtime) + }, + DryRun: dryRunBaseBlockMove, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseBlockMove(runtime) + }, +} diff --git a/shortcuts/base/base_block_ops.go b/shortcuts/base/base_block_ops.go new file mode 100644 index 00000000..706368c8 --- /dev/null +++ b/shortcuts/base/base_block_ops.go @@ -0,0 +1,179 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var baseBlockTypeEnums = []string{"folder", "table", "docx", "dashboard", "workflow"} + +func baseBlockIDFlag(required bool) common.Flag { + return common.Flag{Name: "block-id", Desc: "block id", Required: required} +} + +func dryRunBaseBlockList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/blocks/list"). + Body(buildBaseBlockListBody(runtime)). + Set("base_token", runtime.Str("base-token")) +} + +func dryRunBaseBlockCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/blocks"). + Body(buildBaseBlockCreateBody(runtime)). + Set("base_token", runtime.Str("base-token")) +} + +func dryRunBaseBlockMove(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/move"). + Body(buildBaseBlockMoveBody(runtime)). + Set("base_token", runtime.Str("base-token")). + Set("block_id", runtime.Str("block-id")) +} + +func dryRunBaseBlockRename(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST("/open-apis/base/v3/bases/:base_token/blocks/:block_id/rename"). + Body(map[string]interface{}{"name": strings.TrimSpace(runtime.Str("name"))}). + Set("base_token", runtime.Str("base-token")). + Set("block_id", runtime.Str("block-id")) +} + +func dryRunBaseBlockDelete(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + DELETE("/open-apis/base/v3/bases/:base_token/blocks/:block_id"). + Set("base_token", runtime.Str("base-token")). + Set("block_id", runtime.Str("block-id")) +} + +func validateBaseBlockCreate(runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("name")) == "" { + return common.FlagErrorf("--name must not be blank") + } + if strings.TrimSpace(runtime.Str("type")) == "" { + return common.FlagErrorf("--type must not be blank") + } + return nil +} + +func validateBaseBlockMove(runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("before-id")) != "" && strings.TrimSpace(runtime.Str("after-id")) != "" { + return common.FlagErrorf("--before-id and --after-id are mutually exclusive") + } + return nil +} + +func validateBaseBlockRename(runtime *common.RuntimeContext) error { + if strings.TrimSpace(runtime.Str("name")) == "" { + return common.FlagErrorf("--name must not be blank") + } + return nil +} + +func executeBaseBlockList(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", "list"), nil, buildBaseBlockListBody(runtime)) + if err != nil { + return err + } + filterBaseBlockListData(data, strings.TrimSpace(runtime.Str("type"))) + runtime.Out(data, nil) + return nil +} + +func executeBaseBlockCreate(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks"), nil, buildBaseBlockCreateBody(runtime)) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"block": data, "created": true}, nil) + return nil +} + +func executeBaseBlockMove(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "move"), nil, buildBaseBlockMoveBody(runtime)) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"block": data, "moved": true}, nil) + return nil +} + +func executeBaseBlockRename(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id"), "rename"), nil, map[string]interface{}{ + "name": strings.TrimSpace(runtime.Str("name")), + }) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"block": data, "renamed": true}, nil) + return nil +} + +func executeBaseBlockDelete(runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", runtime.Str("base-token"), "blocks", runtime.Str("block-id")), nil, nil) + if err != nil { + return err + } + runtime.Out(map[string]interface{}{"block": data, "deleted": true}, nil) + return nil +} + +func buildBaseBlockListBody(runtime *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{} + if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" { + body["parent_id"] = parentID + } + return body +} + +func filterBaseBlockListData(data map[string]interface{}, blockType string) { + if blockType == "" { + return + } + blocks, ok := data["blocks"].([]interface{}) + if !ok { + return + } + filtered := make([]interface{}, 0, len(blocks)) + for _, block := range blocks { + blockMap, ok := block.(map[string]interface{}) + if !ok || blockMap["type"] != blockType { + continue + } + filtered = append(filtered, block) + } + data["blocks"] = filtered + data["total"] = len(filtered) +} + +func buildBaseBlockCreateBody(runtime *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{ + "type": strings.TrimSpace(runtime.Str("type")), + "name": strings.TrimSpace(runtime.Str("name")), + } + if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" { + body["parent_id"] = parentID + } + return body +} + +func buildBaseBlockMoveBody(runtime *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{"parent_id": nil} + if parentID := strings.TrimSpace(runtime.Str("parent-id")); parentID != "" { + body["parent_id"] = parentID + } + if beforeID := strings.TrimSpace(runtime.Str("before-id")); beforeID != "" { + body["before_id"] = beforeID + } + if afterID := strings.TrimSpace(runtime.Str("after-id")); afterID != "" { + body["after_id"] = afterID + } + return body +} diff --git a/shortcuts/base/base_block_rename.go b/shortcuts/base/base_block_rename.go new file mode 100644 index 00000000..f1926898 --- /dev/null +++ b/shortcuts/base/base_block_rename.go @@ -0,0 +1,37 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var BaseBaseBlockRename = common.Shortcut{ + Service: "base", + Command: "+base-block-rename", + Description: "Rename a block", + Risk: "write", + Scopes: []string{"base:block:update"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + baseBlockIDFlag(true), + {Name: "name", Desc: "new unique block name; must not duplicate another block name in this base", Required: true}, + }, + Tips: []string{ + "Example: lark-cli base +base-block-rename --base-token --block-id --name \"New name\"", + "Renames the block identified by --block-id.", + "Block names must be unique in the base; use +base-block-list first when you need to check existing names.", + "Use +base-block-list first when you need to resolve the target block id from a visible name.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateBaseBlockRename(runtime) + }, + DryRun: dryRunBaseBlockRename, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + return executeBaseBlockRename(runtime) + }, +} diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 49c23011..32993008 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -32,6 +32,29 @@ func TestDryRunTableOps(t *testing.T) { assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1") } +func TestDryRunBaseBlockOps(t *testing.T) { + ctx := context.Background() + + listRT := newBaseTestRuntime(map[string]string{"base-token": "app_x"}, nil, nil) + assertDryRunContains(t, dryRunBaseBlockList(ctx, listRT), "POST /open-apis/base/v3/bases/app_x/blocks/list") + + listFolderRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "parent-id": "bfl_1", "type": "docx"}, nil, nil) + assertDryRunContains(t, dryRunBaseBlockList(ctx, listFolderRT), "POST /open-apis/base/v3/bases/app_x/blocks/list", `"parent_id":"bfl_1"`) + + createRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "type": "docx", "name": "Spec", "parent-id": "bfl_1"}, nil, nil) + assertDryRunContains(t, dryRunBaseBlockCreate(ctx, createRT), "POST /open-apis/base/v3/bases/app_x/blocks", `"type":"docx"`, `"name":"Spec"`, `"parent_id":"bfl_1"`) + + moveRootRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1"}, nil, nil) + assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveRootRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":null`) + + moveAfterRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "parent-id": "bfl_1", "after-id": "blk_0"}, nil, nil) + assertDryRunContains(t, dryRunBaseBlockMove(ctx, moveAfterRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/move", `"parent_id":"bfl_1"`, `"after_id":"blk_0"`) + + renameRT := newBaseTestRuntime(map[string]string{"base-token": "app_x", "block-id": "blk_1", "name": "New name"}, nil, nil) + assertDryRunContains(t, dryRunBaseBlockRename(ctx, renameRT), "POST /open-apis/base/v3/bases/app_x/blocks/blk_1/rename", `"name":"New name"`) + assertDryRunContains(t, dryRunBaseBlockDelete(ctx, renameRT), "DELETE /open-apis/base/v3/bases/app_x/blocks/blk_1") +} + func TestDryRunFieldOps(t *testing.T) { ctx := context.Background() diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index e1ed7ee2..1d5ca158 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -411,6 +411,108 @@ func decodeCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interf return body } +func TestBaseBlockExecuteShortcuts(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + listStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/blocks/list", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "blocks": []interface{}{ + map[string]interface{}{"id": "blk_doc", "type": "docx", "name": "Spec"}, + map[string]interface{}{"id": "blk_folder", "type": "folder", "name": "Folder"}, + }, + "total": 2, + }, + }, + } + createStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/blocks", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"block_id": "blk_doc", "type": "docx", "name": "Spec"}, + }, + } + moveStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/move", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"block_id": "blk_doc", "parent_id": "bfl_1"}, + }, + } + renameStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc/rename", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"block_id": "blk_doc", "name": "Final Spec"}, + }, + } + deleteStub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/blocks/blk_doc", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"block_id": "blk_doc"}, + }, + } + for _, stub := range []*httpmock.Stub{listStub, createStub, moveStub, renameStub, deleteStub} { + reg.Register(stub) + } + + if err := runShortcut(t, BaseBaseBlockList, []string{"+base-block-list", "--base-token", "app_x", "--parent-id", "bfl_1", "--type", "docx"}, factory, stdout); err != nil { + t.Fatalf("list err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"total": 1`) || !strings.Contains(got, `"blk_doc"`) || strings.Contains(got, `"blk_folder"`) { + t.Fatalf("list stdout=%s", got) + } + if body := decodeCapturedJSONBody(t, listStub); body["parent_id"] != "bfl_1" || body["type"] != nil { + t.Fatalf("list body=%#v", body) + } + + if err := runShortcut(t, BaseBaseBlockCreate, []string{"+base-block-create", "--base-token", "app_x", "--type", "docx", "--name", " Spec ", "--parent-id", "bfl_1"}, factory, stdout); err != nil { + t.Fatalf("create err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"blk_doc"`) { + t.Fatalf("create stdout=%s", got) + } + createBody := decodeCapturedJSONBody(t, createStub) + if createBody["type"] != "docx" || createBody["name"] != "Spec" || createBody["parent_id"] != "bfl_1" { + t.Fatalf("create body=%#v", createBody) + } + + if err := runShortcut(t, BaseBaseBlockMove, []string{"+base-block-move", "--base-token", "app_x", "--block-id", "blk_doc", "--parent-id", "bfl_1", "--after-id", "blk_prev"}, factory, stdout); err != nil { + t.Fatalf("move err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"moved": true`) { + t.Fatalf("move stdout=%s", got) + } + moveBody := decodeCapturedJSONBody(t, moveStub) + if moveBody["parent_id"] != "bfl_1" || moveBody["after_id"] != "blk_prev" { + t.Fatalf("move body=%#v", moveBody) + } + + if err := runShortcut(t, BaseBaseBlockRename, []string{"+base-block-rename", "--base-token", "app_x", "--block-id", "blk_doc", "--name", " Final Spec "}, factory, stdout); err != nil { + t.Fatalf("rename err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"renamed": true`) || !strings.Contains(got, `"Final Spec"`) { + t.Fatalf("rename stdout=%s", got) + } + if body := decodeCapturedJSONBody(t, renameStub); body["name"] != "Final Spec" { + t.Fatalf("rename body=%#v", body) + } + + if err := runShortcut(t, BaseBaseBlockDelete, []string{"+base-block-delete", "--base-token", "app_x", "--block-id", "blk_doc", "--yes"}, factory, stdout); err != nil { + t.Fatalf("delete err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"deleted": true`) || !strings.Contains(got, `"blk_doc"`) { + t.Fatalf("delete stdout=%s", got) + } +} + func TestBaseHistoryExecute(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 24d01a21..e304d4f1 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -133,6 +133,7 @@ func TestViewSetVisibleFieldsValidateHook(t *testing.T) { func TestShortcutsCatalog(t *testing.T) { shortcuts := Shortcuts() want := []string{ + "+base-block-list", "+base-block-create", "+base-block-move", "+base-block-rename", "+base-block-delete", "+table-list", "+table-get", "+table-create", "+table-update", "+table-delete", "+field-list", "+field-get", "+field-create", "+field-update", "+field-delete", "+field-search-options", "+view-list", "+view-get", "+view-create", "+view-delete", "+view-get-filter", "+view-set-filter", "+view-get-visible-fields", "+view-set-visible-fields", "+view-get-group", "+view-set-group", "+view-get-sort", "+view-set-sort", "+view-get-timebar", "+view-set-timebar", "+view-get-card", "+view-set-card", "+view-rename", @@ -188,6 +189,7 @@ func TestBaseDeleteShortcutsRisk(t *testing.T) { BaseFormQuestionsDelete.Command: BaseFormQuestionsDelete.Risk, BaseDashboardDelete.Command: BaseDashboardDelete.Risk, BaseDashboardBlockDelete.Command: BaseDashboardBlockDelete.Risk, + BaseBaseBlockDelete.Command: BaseBaseBlockDelete.Risk, BaseRoleDelete.Command: BaseRoleDelete.Risk, } @@ -241,6 +243,30 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) { } } +func TestBaseBlockMoveRejectsBeforeAndAfter(t *testing.T) { + runtime := newBaseTestRuntime( + map[string]string{"before-id": "blk_before", "after-id": "blk_after"}, + nil, + nil, + ) + err := validateBaseBlockMove(runtime) + if err == nil || !strings.Contains(err.Error(), "--before-id and --after-id are mutually exclusive") { + t.Fatalf("err=%v", err) + } +} + +func TestBaseBlockCreateAndRenameRequireName(t *testing.T) { + createRT := newBaseTestRuntime(map[string]string{"type": "folder", "name": " "}, nil, nil) + if err := validateBaseBlockCreate(createRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") { + t.Fatalf("create err=%v", err) + } + + renameRT := newBaseTestRuntime(map[string]string{"name": " "}, nil, nil) + if err := validateBaseBlockRename(renameRT); err == nil || !strings.Contains(err.Error(), "--name must not be blank") { + t.Fatalf("rename err=%v", err) + } +} + func TestBaseRecordReadHelpGuidesAgents(t *testing.T) { tests := []struct { name string @@ -728,6 +754,79 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) { } } +func TestBaseBlockHelpGuidesAgents(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + wantTips []string + }{ + { + name: "list", + shortcut: BaseBaseBlockList, + wantTips: []string{ + "lark-cli base +base-block-list --base-token ", + "lark-cli base +base-block-list --base-token --type table", + "lark-cli base +base-block-list --base-token --parent-id ", + `jq '.blocks[] | {type, name, block_id: .id, parent_id}'`, + `--type docx | jq '.blocks[] | {name, docx_token}'`, + "returned id is the table-id, dashboard-id, or workflow-id", + "For docx blocks, use the returned docx_token with docx commands.", + }, + }, + { + name: "create", + shortcut: BaseBaseBlockCreate, + wantTips: []string{ + `lark-cli base +base-block-create --base-token --type folder --name "Project Docs"`, + `lark-cli base +base-block-create --base-token --type table --name "Tasks"`, + `lark-cli base +base-block-create --base-token --type docx --name "Spec" --parent-id `, + `lark-cli base +base-block-create --base-token --type dashboard --name "Metrics"`, + `lark-cli base +base-block-create --base-token --type workflow --name "Approval Flow"`, + }, + }, + { + name: "move", + shortcut: BaseBaseBlockMove, + wantTips: []string{ + "lark-cli base +base-block-move --base-token --block-id --parent-id ", + "lark-cli base +base-block-move --base-token --block-id --after-id ", + "lark-cli base +base-block-move --base-token --block-id --before-id ", + "lark-cli base +base-block-move --base-token --block-id ", + }, + }, + { + name: "rename", + shortcut: BaseBaseBlockRename, + wantTips: []string{ + `lark-cli base +base-block-rename --base-token --block-id --name "New name"`, + }, + }, + { + name: "delete", + shortcut: BaseBaseBlockDelete, + wantTips: []string{ + "lark-cli base +base-block-delete --base-token --block-id --yes", + "Recursive folder deletion is not supported.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + parent := &cobra.Command{Use: "base"} + tt.shortcut.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + + tips := strings.Join(cmdutil.GetTips(cmd), "\n") + for _, want := range tt.wantTips { + if !strings.Contains(tips, want) { + t.Fatalf("tips missing %q:\n%s", want, tips) + } + } + }) + } +} + func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) { parent := &cobra.Command{Use: "base"} BaseFieldUpdate.Mount(parent, &cmdutil.Factory{}) diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go index 13f2a921..42fa883a 100644 --- a/shortcuts/base/shortcuts.go +++ b/shortcuts/base/shortcuts.go @@ -8,6 +8,11 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all base shortcuts. func Shortcuts() []common.Shortcut { return []common.Shortcut{ + BaseBaseBlockList, + BaseBaseBlockCreate, + BaseBaseBlockMove, + BaseBaseBlockRename, + BaseBaseBlockDelete, BaseTableList, BaseTableGet, BaseTableCreate, diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 3e78994b..1bfab719 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -41,7 +41,9 @@ metadata: |---|---|---| | 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token | | 创建/复制 Base | `+base-create` / `+base-copy` | 写入后报告新 Base 标识;注意返回中的 `permission_grant` | -| 管理表 | `+table-list/get/create/update/delete` | `+table-create --fields` 复杂时读 `lark-base-field-json.md` | +| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` | +| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow;资源内容继续用对应命令 | +| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 | | 列/查/删字段 | `+field-list/get/delete/search-options` | 写入前用 list/get 确认字段类型、选项、ID;删除前确认目标字段 | | 创建/更新字段 | `+field-create` / `+field-update` | 必读 [lark-base-field-json.md](references/lark-base-field-json.md);公式读 [formula-field-guide.md](references/formula-field-guide.md);lookup 读 [lookup-field-guide.md](references/lookup-field-guide.md);命令细节读 [lark-base-field-create.md](references/lark-base-field-create.md) / [lark-base-field-update.md](references/lark-base-field-update.md) | | 读记录明细 | `+record-get` / `+record-list` / `+record-search` | 涉及筛选、排序、Top/Bottom N、聚合、多表关联、全局结论时读 [lark-base-data-analysis-sop.md](references/lark-base-data-analysis-sop.md) | @@ -62,6 +64,8 @@ metadata: ## Base 心智模型 - Base 曾用名 Bitable;返回字段、错误或旧文档里的 `bitable` 多为历史兼容,不代表应改走裸 API 或另一套命令。 +- `+base-block-list` 是查看一个 Base 内资源目录的新入口:它列出这个 Base 直接管理的 `folder/table/docx/dashboard/workflow`,适合先判断 Base 里有什么,再决定走 table、dashboard、workflow 或 docx 命令。 +- `base-block` 只负责资源目录管理,包括创建资源、移动到 folder、重命名和删除;具体资源内容仍走 table/dashboard/workflow 命令。 - 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。 - 存储字段可写;系统字段、`formula`、`lookup` 只读;附件字段走专用 attachment 命令。 - 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。 diff --git a/tests/cli_e2e/base/base_block_dryrun_test.go b/tests/cli_e2e/base/base_block_dryrun_test.go new file mode 100644 index 00000000..fab8c423 --- /dev/null +++ b/tests/cli_e2e/base/base_block_dryrun_test.go @@ -0,0 +1,154 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBaseBlockDryRun(t *testing.T) { + setBaseDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + t.Run("list all", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-block-list", + "--base-token", "app_x", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/list", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out) + require.False(t, gjson.Get(out, "api.0.body.parent_id").Exists(), out) + }) + + t.Run("list folder", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-block-list", + "--base-token", "app_x", + "--parent-id", "blk_folder", + "--type", "docx", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/list", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "blk_folder", gjson.Get(out, "api.0.body.parent_id").String(), out) + require.False(t, gjson.Get(out, "api.0.body.type").Exists(), out) + }) + + t.Run("create", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-block-create", + "--base-token", "app_x", + "--type", "docx", + "--name", "Spec", + "--parent-id", "blk_folder", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out) + require.Equal(t, "docx", gjson.Get(out, "api.0.body.type").String(), out) + require.Equal(t, "Spec", gjson.Get(out, "api.0.body.name").String(), out) + require.Equal(t, "blk_folder", gjson.Get(out, "api.0.body.parent_id").String(), out) + }) + + t.Run("move root", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-block-move", + "--base-token", "app_x", + "--block-id", "blk_a", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/move", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out) + require.True(t, gjson.Get(out, "api.0.body.parent_id").Exists(), out) + require.Equal(t, "Null", gjson.Get(out, "api.0.body.parent_id").Type.String(), out) + }) + + t.Run("move after", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-block-move", + "--base-token", "app_x", + "--block-id", "blk_a", + "--parent-id", "blk_folder", + "--after-id", "blk_b", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/move", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "blk_folder", gjson.Get(out, "api.0.body.parent_id").String(), out) + require.Equal(t, "blk_b", gjson.Get(out, "api.0.body.after_id").String(), out) + }) + + t.Run("rename", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-block-rename", + "--base-token", "app_x", + "--block-id", "blk_a", + "--name", "Renamed", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a/rename", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "POST", gjson.Get(out, "api.0.method").String(), out) + require.Equal(t, "Renamed", gjson.Get(out, "api.0.body.name").String(), out) + }) + + t.Run("delete", func(t *testing.T) { + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+base-block-delete", + "--base-token", "app_x", + "--block-id", "blk_a", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + out := result.Stdout + require.Equal(t, "/open-apis/base/v3/bases/app_x/blocks/blk_a", gjson.Get(out, "api.0.url").String(), out) + require.Equal(t, "DELETE", gjson.Get(out, "api.0.method").String(), out) + }) +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index b1b7f80a..b3b2435b 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -1,12 +1,13 @@ # Base CLI E2E Coverage ## Metrics -- Denominator: 73 leaf commands -- Covered: 10 -- Coverage: 13.7% +- Denominator: 78 leaf commands +- Covered: 18 +- Coverage: 23.1% ## Summary - TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`. +- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data. - TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`. - Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered. - Blocked area: dashboard, field, form, record, view, and workflow operations still lack deterministic create/read/update workflows in this suite. @@ -20,6 +21,11 @@ | ✕ | base +base-copy | shortcut | | none | no copy workflow yet | | ✓ | base +base-create | shortcut | base/helpers_test.go::createBaseWithRetry | `--name`; `--time-zone` | helper asserts created base token | | ✓ | base +base-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get base as bot | `--base-token` | | +| ✓ | base +base-block-create | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/create | `--base-token`; `--type`; `--name`; `--parent-id`; dry-run only | request shape only | +| ✓ | base +base-block-delete | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/delete | `--base-token`; `--block-id`; dry-run only | request shape only | +| ✓ | base +base-block-list | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/list all,list folder | `--base-token`; optional `--parent-id`; optional `--type`; dry-run only | request shape only | +| ✓ | base +base-block-move | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/move root,move after | `--base-token`; `--block-id`; optional `--parent-id`; `--after-id`; dry-run only | request shape only | +| ✓ | base +base-block-rename | shortcut | base_block_dryrun_test.go::TestBaseBlockDryRun/rename | `--base-token`; `--block-id`; `--name`; dry-run only | request shape only | | ✕ | base +dashboard-arrange | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered |