diff --git a/shortcuts/base/base_block_create.go b/shortcuts/base/base_block_create.go new file mode 100644 index 000000000..8e6601b91 --- /dev/null +++ b/shortcuts/base/base_block_create.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 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{ + "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 000000000..f6cca13e2 --- /dev/null +++ b/shortcuts/base/base_block_delete.go @@ -0,0 +1,33 @@ +// 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{ + "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.", + }, + 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 000000000..3eb0313f7 --- /dev/null +++ b/shortcuts/base/base_block_list.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 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{ + "Blocks are entries in the base sidebar, such as folder, table, docx, dashboard, and workflow.", + "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 000000000..6916f5277 --- /dev/null +++ b/shortcuts/base/base_block_move.go @@ -0,0 +1,38 @@ +// 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: "place before this sibling block id"}, + {Name: "after-id", Desc: "place after this sibling block id"}, + }, + Tips: []string{ + "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 000000000..706368c81 --- /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 000000000..75f0344b9 --- /dev/null +++ b/shortcuts/base/base_block_rename.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 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 block name", Required: true}, + }, + Tips: []string{ + "Renames the block identified by --block-id.", + "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 f25b99ac2..938e76bb9 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 0a9a1d709..cbf20ac19 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -410,6 +410,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 9f75ac763..ac143d078 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, } @@ -222,6 +224,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 diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go index c98ccff7e..cc52efa22 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 371191767..b7171e9a0 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -55,6 +55,7 @@ metadata: | 大模块 | 处理什么问题 | 包含的小模块 / 能力 | |------|-------------|-------------------| | Base 模块 | 管理 Base 本体,或从链接进入 Base 场景 | `base-create / base-get / base-copy`,Base / Wiki 链接解析 | +| Base Block 模块 | 管理 Base 容器里的 folder/table/docx/dashboard/workflow 入口 | `base-block-*` | | 表与数据模块 | 管理 Base 内部结构与日常数据操作 | `table / field / record / view` | | 公式 / Lookup 模块 | 处理派生字段、条件判断、跨表计算、固定查找引用 | `formula / lookup` 字段创建与更新 | | 数据分析模块 | 做一次性筛选、分组、聚合分析 | `data-query` | @@ -75,12 +76,26 @@ metadata: | `+base-get` | 获取 Base 信息 | [`lark-base-base-get.md`](references/lark-base-base-get.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 适合确认 Base 本体信息,不替代表/字段结构读取 | | `+base-copy` | 复制已有 Base | [`lark-base-base-copy.md`](references/lark-base-base-copy.md)、[`lark-base-workspace.md`](references/lark-base-workspace.md) | 写入操作;执行前先读 reference;复制成功后应主动返回新 Base 标识信息 | -### 2.3 表与数据模块 +### 2.3 Base Block 模块 + +管理 Base 里的 block,类型有 `folder/table/docx/dashboard/workflow`。`+base-block-list` 返回的 `id` 对于 table/dashboard/workflow 就是对应资源 ID,可直接用于调用 table/dashboard/workflow 命令。docx block 会返回 docx token,把这个 token 交给 docx CLI/skill 继续操作 docx 文档内容。 + +> `base-block` 是 Base 容器条目;`dashboard-block` 是仪表盘内部图表/组件。不要混用。 + +| 命令 | 用途 / 何时使用 | 路由提醒 | +|------|------------------|----------| +| `+base-block-list` | 列出所有 folder/table/docx/dashboard/workflow,可按 folder 或 type 过滤 | | +| `+base-block-create` | 创建 folder/table/docx/dashboard/workflow block | 具体参数看 `--help` | +| `+base-block-move` | 移动 block 或调整同级顺序 | 具体定位参数看 `--help` | +| `+base-block-rename` | 重命名 block | | +| `+base-block-delete` | 删除 block | 高风险操作;执行前看 `--help` | + +### 2.4 表与数据模块 这是最常用的大模块,包含 `table / field / record / view` 四类子模块。 补充示例:[`references/examples.md`](references/examples.md),适合需要串联 table / record / view 完整操作链路时再读。 -#### 2.3.1 Table 子模块 +#### 2.4.1 Table 子模块 子模块索引:[`references/lark-base-table.md`](references/lark-base-table.md) @@ -89,7 +104,7 @@ metadata: | `+table-list / +table-get` | 列出数据表,或获取单个表详情 | [`lark-base-table-list.md`](references/lark-base-table-list.md)、[`lark-base-table-get.md`](references/lark-base-table-get.md) | `+table-list` 只能串行执行;`+table-get` 适合删除/修改前确认目标 | | `+table-create / +table-update / +table-delete` | 创建、更新或删除数据表 | [`lark-base-table-create.md`](references/lark-base-table-create.md)、[`lark-base-table-update.md`](references/lark-base-table-update.md)、[`lark-base-table-delete.md`](references/lark-base-table-delete.md) | 创建适合一次性建表;更新前先确认目标表;删除时用户已明确目标可直接执行并带 `--yes` | -#### 2.3.2 Field 子模块 +#### 2.4.2 Field 子模块 普通字段管理走这里;如果字段类型是 `formula` 或 `lookup`,转到下方“公式 / Lookup 模块”。 子模块索引:[`references/lark-base-field.md`](references/lark-base-field.md) @@ -100,7 +115,7 @@ metadata: | `+field-create / +field-update / +field-delete` | 创建、更新或删除普通字段 | [`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-field-delete.md`](references/lark-base-field-delete.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 写字段前先看字段属性规范;如果涉及类型转换,直接按 `+field-update` 中的字段类型变更规则执行,只在安全白名单内考虑原地转换;如果类型是 `formula / lookup`,先转去读对应 guide;更新或删除时用户已明确目标可直接执行并带 `--yes` | | `+field-search-options` | 查询字段可选项 | [`lark-base-field-search-options.md`](references/lark-base-field-search-options.md) | 适合单选/多选等选项型字段 | -#### 2.3.3 Record 子模块 +#### 2.4.3 Record 子模块 子模块索引:[`references/lark-base-record.md`](references/lark-base-record.md)、[`references/lark-base-history.md`](references/lark-base-history.md) @@ -115,7 +130,7 @@ metadata: | `+record-history-list` | 查询指定记录的变更历史 | [`lark-base-record-history-list.md`](references/lark-base-record-history-list.md) | 按 `table-id + record-id` 查询,不支持整表扫描;`+record-history-list` 只能串行执行 | | `+record-share-link-create` | 为一条或多条记录生成分享链接 | [`lark-base-record-share-link-create.md`](references/lark-base-record-share-link-create.md) | 单次最多 100 条;重复 record_id 会自动去重;适合分享单条记录或批量分享场景 | -#### 2.3.4 View 子模块 +#### 2.4.4 View 子模块 子模块索引:[`references/lark-base-view.md`](references/lark-base-view.md) @@ -130,7 +145,7 @@ metadata: | `+view-get-card / +view-set-card` | 读取或配置卡片视图 | [`lark-base-view-get-card.md`](references/lark-base-view-get-card.md)、[`lark-base-view-set-card.md`](references/lark-base-view-set-card.md) | 适合卡片展示场景 | | `+view-get-timebar / +view-set-timebar` | 读取或配置时间轴视图 | [`lark-base-view-get-timebar.md`](references/lark-base-view-get-timebar.md)、[`lark-base-view-set-timebar.md`](references/lark-base-view-set-timebar.md) | 适合时间线展示场景 | -### 2.4 公式 / Lookup 模块 +### 2.5 公式 / Lookup 模块 只要用户诉求涉及派生指标、条件判断、文本处理、日期差、跨表计算、跨表筛选后取值,都要先判断是否进入本模块。 @@ -144,7 +159,7 @@ metadata: | `+field-create`(`type=lookup`) | 创建 lookup 字段 | [`lookup-field-guide.md`](references/lookup-field-guide.md)、[`lark-base-field-create.md`](references/lark-base-field-create.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 没读 guide 前不要直接创建 | | `+field-update`(`type=lookup`) | 更新 lookup 字段 | [`lookup-field-guide.md`](references/lookup-field-guide.md)、[`lark-base-field-update.md`](references/lark-base-field-update.md)、[`lark-base-shortcut-field-properties.md`](references/lark-base-shortcut-field-properties.md) | 跨表时还要拿目标表结构 | -### 2.5 数据分析模块 +### 2.6 数据分析模块 用于一次性分析和临时聚合查询。用户要的是“这次算出来的结果”,而不是把结果沉淀成字段时,优先进入本模块。 @@ -158,7 +173,7 @@ metadata: |------|------------------|----------------|----------| | `+data-query` | 做分组统计、SUM / AVG / COUNT / MAX / MIN、条件筛选后的聚合分析 | [`lark-base-data-query.md`](references/lark-base-data-query.md) | 字段名必须精确匹配真实字段名;不要用 `+record-list` / `+record-search` 拉全量再手算;`+data-query` 不返回原始记录;使用前先确认权限和字段类型是否受支持 | -### 2.6 Workflow 模块 +### 2.7 Workflow 模块 这是高约束模块。执行任何 workflow 命令前,都必须先读对应命令文档和 schema。 模块索引:[`references/lark-base-workflow.md`](references/lark-base-workflow.md) @@ -169,7 +184,7 @@ metadata: | `+workflow-create / +workflow-update` | 创建或更新 workflow | [`lark-base-workflow-create.md`](references/lark-base-workflow-create.md)、[`lark-base-workflow-update.md`](references/lark-base-workflow-update.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | 先读 schema;禁止凭自然语言猜 `type`;先确认真实表名和字段名 | | `+workflow-enable / +workflow-disable` | 启用或停用 workflow | [`lark-base-workflow-enable.md`](references/lark-base-workflow-enable.md)、[`lark-base-workflow-disable.md`](references/lark-base-workflow-disable.md)、[`lark-base-workflow-schema.md`](references/lark-base-workflow-schema.md) | 启用或停用前先确认目标 workflow;`workflow_id` 与 `table_id` 需按前缀区分 | -### 2.7 Dashboard 模块 +### 2.8 Dashboard 模块 当用户提到“仪表盘、dashboard、数据看板、图表、可视化、block、组件、添加组件、创建图表”等关键词时,进入本模块,并先阅读 [`lark-base-dashboard.md`](references/lark-base-dashboard.md)。 @@ -180,7 +195,7 @@ metadata: | `+dashboard-block-list / +dashboard-block-get` | 列出图表组件,或获取单个 block 详情 | [`lark-base-dashboard-block-list.md`](references/lark-base-dashboard-block-list.md)、[`lark-base-dashboard-block-get.md`](references/lark-base-dashboard-block-get.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | `+dashboard-block-list` 只能串行执行;查看配置细节时读 block config 文档 | | `+dashboard-block-create / +dashboard-block-update / +dashboard-block-delete` | 创建、更新或删除图表组件 | [`lark-base-dashboard-block-create.md`](references/lark-base-dashboard-block-create.md)、[`lark-base-dashboard-block-update.md`](references/lark-base-dashboard-block-update.md)、[`lark-base-dashboard-block-delete.md`](references/lark-base-dashboard-block-delete.md)、[`lark-base-dashboard.md`](references/lark-base-dashboard.md)、[`dashboard-block-data-config.md`](references/dashboard-block-data-config.md) | 涉及 `data_config`、图表类型、filter 时要读 block config 文档;删除前先确认目标 | -### 2.8 表单模块 +### 2.9 表单模块 用于管理表单本体和表单题目。 模块索引:[`references/lark-base-form.md`](references/lark-base-form.md)、[`references/lark-base-form-questions.md`](references/lark-base-form-questions.md) @@ -195,7 +210,7 @@ metadata: | `+form-questions-list` | 列出表单题目 | [`lark-base-form-questions-list.md`](references/lark-base-form-questions-list.md) | 适合查看已有题目结构 | | `+form-questions-create / +form-questions-update / +form-questions-delete` | 创建、更新或删除题目 | [`lark-base-form-questions-create.md`](references/lark-base-form-questions-create.md)、[`lark-base-form-questions-update.md`](references/lark-base-form-questions-update.md)、[`lark-base-form-questions-delete.md`](references/lark-base-form-questions-delete.md) | 先确认 `form-id`;更新或删除前先确认题目目标 | -### 2.9 权限与角色模块 +### 2.10 权限与角色模块 用于启用高级权限,以及管理 Base 自定义角色。 涉及 `+advperm-enable / +advperm-disable / +role-*` 时,操作用户必须为 Base 管理员,否则会返回权限错误。 diff --git a/skills/lark-base/references/lark-base-base-block.md b/skills/lark-base/references/lark-base-base-block.md new file mode 100644 index 000000000..c974e9fe2 --- /dev/null +++ b/skills/lark-base/references/lark-base-base-block.md @@ -0,0 +1,35 @@ +# base +base-block-* + +管理 Base 容器里的一级资源入口。这里的 block 是 Base 侧边栏/容器管理的条目,包括 folder、table、docx、dashboard、workflow。 + +> 注意:`base-block` 和 `dashboard-block` 不是同一个概念。`base-block` 是 Base 容器条目;`dashboard-block` 是仪表盘内部的图表/组件。 + +## 命令选择 + +| 目标 | 命令 | 关键参数 | +|------|------|----------| +| 列出 Base 容器条目 | `+base-block-list` | `--base-token`,可选 `--parent-id` | +| 创建 folder/table/docx/dashboard/workflow 条目 | `+base-block-create` | `--base-token`、`--type`、`--name`,可选 `--parent-id` | +| 移动条目到根或文件夹、调整同级位置 | `+base-block-move` | `--base-token`、`--block-id`,可选 `--parent-id`、`--before-id`、`--after-id` | +| 重命名条目 | `+base-block-rename` | `--base-token`、`--block-id`、`--name` | +| 删除条目 | `+base-block-delete` | `--base-token`、`--block-id`、`--yes` | + +## 通用约束 + +- `--block-id` 是 Base 容器里的 block id,不是 docx token,也不是 dashboard 内部 chart/widget id。 +- `--parent-id` 是目标 folder 的 block id;创建和移动时不传表示根层级;list 时不传表示列出全部。 +- 当前 CLI 不暴露分页参数。block 总数上限由后端控制,默认一次返回完整列表。 +- 移动时 `--before-id` 和 `--after-id` 互斥。 +- 当前不支持递归删除文件夹。删除非空 folder 时,先移动或删除其子项。 +- 创建出的 docx/dashboard/workflow/table 的具体内容,需要继续用对应模块命令操作。 + +## 示例 + +```bash +lark-cli base +base-block-list --base-token app_xxx +lark-cli base +base-block-create --base-token app_xxx --type folder --name "项目资料" +lark-cli base +base-block-create --base-token app_xxx --type docx --name "需求文档" --parent-id blk_folder +lark-cli base +base-block-move --base-token app_xxx --block-id blk_docx --parent-id blk_folder --after-id blk_table +lark-cli base +base-block-rename --base-token app_xxx --block-id blk_docx --name "新的名称" +lark-cli base +base-block-delete --base-token app_xxx --block-id blk_docx --yes +``` 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 000000000..fab8c4235 --- /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 b1b7f80a1..b3b2435b5 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 |