Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions shortcuts/base/base_dryrun_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,47 @@ func TestDryRunFieldOps(t *testing.T) {
}
}

func TestDryRunButtonRuleOps(t *testing.T) {
ctx := context.Background()
rt := newBaseTestRuntime(
map[string]string{
"base-token": "app_x",
"table-id": "tbl_1",
"field-id": "fld_button",
"workflow-id": "wkf_1",
},
nil,
nil,
)

assertDryRunContains(t, dryRunButtonBind(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_button/button_rule", `"workflow_id":"wkf_1"`)
assertDryRunContains(t, dryRunButtonGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_button/button_rule")
assertDryRunContains(t, dryRunButtonUnbind(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_button/button_rule")
}
Comment on lines +122 to +138

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Add mounted dry-run and live E2E coverage for the new shortcuts.

This test calls dryRunButtonBind, dryRunButtonGet, and dryRunButtonUnbind directly. It does not cover shortcut mounting, flag wiring, validation routing, confirmation behavior, or Execute request plumbing.

Add shortcut-level dry-run E2E tests. Add self-contained live E2E coverage that creates the workflow and button field, binds, gets, unbinds, and cleans up the resources.

As per coding guidelines: “Shortcut changes require dry-run E2E coverage” and “new shortcuts require live E2E coverage.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/base/base_dryrun_ops_test.go` around lines 122 - 138, Add mounted
shortcut-level dry-run E2E coverage for the button-rule shortcut, exercising
mounting, flag wiring, validation, confirmation, and Execute request plumbing
instead of calling dryRunButtonBind/Get/Unbind directly. Add self-contained live
E2E coverage that creates the workflow and button field, binds and retrieves the
rule, unbinds it, and cleans up all created resources.

Source: Coding guidelines


func TestValidateButtonRuleOps(t *testing.T) {
valid := newBaseTestRuntime(map[string]string{
"base-token": "app_x", "table-id": "tbl_1", "field-id": "fld_button", "workflow-id": "wkfAbcdefg",
}, nil, nil)
if err := validateButtonBind(valid); err != nil {
t.Fatalf("valid button bind rejected: %v", err)
}

badWorkflow := newBaseTestRuntime(map[string]string{
"base-token": "app_x", "table-id": "tbl_1", "field-id": "fld_button", "workflow-id": "tblAbcdefg",
}, nil, nil)
if err := validateButtonBind(badWorkflow); err == nil || !strings.Contains(err.Error(), "wkf prefix") {
t.Fatalf("expected public workflow ID validation error, got %v", err)
}

missingField := newBaseTestRuntime(map[string]string{
"base-token": "app_x", "table-id": "tbl_1", "workflow-id": "wkfAbcdefg",
}, nil, nil)
if err := validateButtonRuleLocator(missingField); err == nil || !strings.Contains(err.Error(), "--field-id") {
t.Fatalf("expected missing field validation error, got %v", err)
Comment on lines +151 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert typed validation error metadata.

These checks only inspect err.Error(). They pass if the validation path regresses from a typed error to a plain error.

Assert the declared typed validation error, its category, subtype, and Param for --workflow-id and --field-id. Preserve and assert a cause when the tested path has one.

As per coding guidelines: “Error tests must assert typed metadata and cause preservation rather than message text alone.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/base/base_dryrun_ops_test.go` around lines 151 - 159, The
validation tests around validateButtonBind and validateButtonRuleLocator
currently assert only message text; update them to type-assert the declared
validation error and verify its category, subtype, and Param values for
--workflow-id and --field-id. Where the validation path wraps an underlying
cause, also assert that the cause is preserved, while removing reliance on
strings.Contains as the primary assertion.

Source: Coding guidelines

}
}

func TestDryRunRecordOps(t *testing.T) {
ctx := context.Background()

Expand Down
1 change: 1 addition & 0 deletions shortcuts/base/base_shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ func TestShortcutsCatalog(t *testing.T) {
"+base-get", "+base-copy", "+base-create",
"+role-create", "+role-delete", "+role-update", "+role-list", "+role-get", "+advperm-enable", "+advperm-disable",
"+workflow-list", "+workflow-get", "+workflow-create", "+workflow-update", "+workflow-enable", "+workflow-disable",
"+button-bind", "+button-get", "+button-unbind",
"+data-query",
"+form-create", "+form-delete", "+form-list", "+form-update", "+form-get", "+form-detail",
"+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list",
Expand Down
178 changes: 178 additions & 0 deletions shortcuts/base/button_rule.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import (
"context"
"strings"

"github.com/larksuite/cli/shortcuts/common"
)

var BaseButtonBind = common.Shortcut{
Service: "base",
Command: "+button-bind",
Description: "Bind a button field to a workflow",
Risk: "write",
Scopes: []string{"base:field:update", "base:workflow:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
fieldRefFlag(true),
{Name: "workflow-id", Desc: "workflow ID (wkf... prefix)", Required: true},
},
Tips: []string{
"Create the button-trigger workflow first, then create the button field, then bind them with this command.",
"Button field JSON must not include workflow_id; binding is managed only through button_rule APIs.",
"workflow-id must start with wkf; do not pass a tbl table ID or raw internal automation ID.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateButtonBind(runtime)
},
DryRun: dryRunButtonBind,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeButtonBind(runtime)
},
}

var BaseButtonGet = common.Shortcut{
Service: "base",
Command: "+button-get",
Description: "Get the workflow bound to a button field",
Risk: "read",
Scopes: []string{"base:field:read", "base:workflow:read"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
fieldRefFlag(true),
},
Tips: []string{
"Returns the button_rule binding for a button field; use +workflow-get for workflow details.",
"The binding workflow_id is the public wkf-prefixed workflow ID.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateButtonRuleLocator(runtime)
},
DryRun: dryRunButtonGet,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeButtonGet(runtime)
},
}

var BaseButtonUnbind = common.Shortcut{
Service: "base",
Command: "+button-unbind",
Description: "Unbind a button field from its workflow",
Risk: "high-risk-write",
Scopes: []string{"base:field:update", "base:workflow:update"},
AuthTypes: authTypes(),
Flags: []common.Flag{
baseTokenFlag(true),
tableRefFlag(true),
fieldRefFlag(true),
},
Tips: []string{
"Unbind only removes the button_rule relation; it does not delete the button field or workflow.",
"Agent guidance: for high-risk writes, explain the exact target and pass --yes without asking again when the user has already asked you to perform this action.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateButtonRuleLocator(runtime)
},
DryRun: dryRunButtonUnbind,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeButtonUnbind(runtime)
},
}

func validateButtonRuleLocator(runtime *common.RuntimeContext) error {
if strings.TrimSpace(runtime.Str("base-token")) == "" {
return baseFlagErrorf("--base-token must not be blank")
}
if strings.TrimSpace(baseTableID(runtime)) == "" {
return baseFlagErrorf("--table-id must not be blank")
}
if strings.TrimSpace(runtime.Str("field-id")) == "" {
return baseFlagErrorf("--field-id must not be blank")
}
return nil
}

func validateButtonBind(runtime *common.RuntimeContext) error {
if err := validateButtonRuleLocator(runtime); err != nil {
return err
}
workflowID := strings.TrimSpace(runtime.Str("workflow-id"))
if workflowID == "" {
return baseFlagErrorf("--workflow-id must not be blank")
}
if !strings.HasPrefix(workflowID, "wkf") {
return baseFlagErrorf("--workflow-id must be a public workflow ID with wkf prefix")
}
Comment on lines +107 to +113

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject or normalize surrounding whitespace in workflow-id.

validateButtonBind accepts " wkf_1 " after trimming it. dryRunButtonBind and executeButtonBind then send the raw value with spaces. The API receives an invalid workflow ID.

Reject leading or trailing whitespace before validation succeeds, or use the normalized value in both request bodies. Add a regression test for whitespace-padded input.

Also applies to: 126-134, 152-159

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/base/button_rule.go` around lines 107 - 113, Ensure
validateButtonBind, dryRunButtonBind, and executeButtonBind consistently handle
surrounding whitespace in workflow-id: either reject padded input during
validation or propagate the trimmed workflowID into both request bodies. Add a
regression test covering whitespace-padded workflow IDs and verify the API never
receives the raw spaced value.

return nil
}

func buttonRulePath(runtime *common.RuntimeContext) string {
return baseV3Path(
"bases", runtime.Str("base-token"),
"tables", baseTableID(runtime),
"fields", runtime.Str("field-id"),
"button_rule",
)
}

func dryRunButtonBind(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body := map[string]interface{}{"workflow_id": runtime.Str("workflow-id")}
return common.NewDryRunAPI().
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id/button_rule").
Body(body).
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
Set("field_id", runtime.Str("field-id"))
}

func dryRunButtonGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id/button_rule").
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
Set("field_id", runtime.Str("field-id"))
}

func dryRunButtonUnbind(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id/button_rule").
Set("base_token", runtime.Str("base-token")).
Set("table_id", baseTableID(runtime)).
Set("field_id", runtime.Str("field-id"))
}

func executeButtonBind(runtime *common.RuntimeContext) error {
body := map[string]interface{}{"workflow_id": runtime.Str("workflow-id")}
data, err := baseV3CallAny(runtime, "PUT", buttonRulePath(runtime), nil, body)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"button_rule": data, "bound": true}, nil)
return nil
}

func executeButtonGet(runtime *common.RuntimeContext) error {
data, err := baseV3CallAny(runtime, "GET", buttonRulePath(runtime), nil, nil)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"button_rule": data}, nil)
return nil
}

func executeButtonUnbind(runtime *common.RuntimeContext) error {
data, err := baseV3CallAny(runtime, "DELETE", buttonRulePath(runtime), nil, nil)
if err != nil {
return err
}
runtime.Out(map[string]interface{}{"button_rule": data, "unbound": true}, nil)
return nil
}
2 changes: 2 additions & 0 deletions shortcuts/base/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ func resolveFieldTypeSpec(typeName string) (fieldTypeSpec, error) {
return fieldTypeSpec{Type: "datetime", Extra: map[string]interface{}{"style": map[string]interface{}{"format": "yyyy/MM/dd"}}}, nil
case "checkbox":
return fieldTypeSpec{Type: "checkbox"}, nil
case "button", "buttonfield", "button_field", "button-field":
return fieldTypeSpec{Type: "button"}, nil
Comment on lines +178 to +179

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Add regression coverage for each button alias.

Add table-driven cases for button, buttonfield, button_field, and button-field. The supplied test changes do not verify resolveFieldTypeSpec, so an alias can regress without detection.

As per coding guidelines: “Every behavior change requires a nearby regression test that fails when the implementation is reverted.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shortcuts/base/helpers.go` around lines 178 - 179, Add nearby table-driven
regression tests targeting resolveFieldTypeSpec, with separate cases for button,
buttonfield, button_field, and button-field. Assert each alias resolves to the
button field type, ensuring reverting the alias handling causes the tests to
fail.

Source: Coding guidelines

case "user", "groupchat", "group_chat", "group-chat":
return fieldTypeSpec{Type: "user", Extra: map[string]interface{}{"multiple": true}}, nil
case "attachment":
Expand Down
3 changes: 3 additions & 0 deletions shortcuts/base/shortcuts.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ func Shortcuts() []common.Shortcut {
BaseWorkflowUpdate,
BaseWorkflowEnable,
BaseWorkflowDisable,
BaseButtonBind,
BaseButtonGet,
BaseButtonUnbind,
BaseDataQuery,
BaseFormCreate,
BaseFormDelete,
Expand Down
1 change: 1 addition & 0 deletions skills/lark-base/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ metadata:
| 分享表单详情 | `+form-detail --share-token <share_token>` | 只接受表单分享链接里的 `share_token`,不要传 `--base-token` / `--form-id`;提交前读 [lark-base-form-detail.md](references/lark-base-form-detail.md) |
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取一个或多个图表计算结果用 `+dashboard-block-get-data`;读取完整仪表盘时按 block 类型分流,文本和不支持直接取数的图表按 reference 恢复 |
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md);list/get/enable/disable 只处理 workflow ID 与启停状态 |
| 按钮字段绑定 Workflow | `+button-bind/get/unbind` | 先创建带按钮触发器的 workflow,再创建 `type:"button"` 字段,最后用 `+button-bind` 绑定 `wkf_` workflow ID;字段 JSON 不写 `workflow_id` |
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |

## Base 心智模型
Expand Down
19 changes: 18 additions & 1 deletion skills/lark-base/references/lark-base-field-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
| `lookup` | `type` `name` `from` `select` `where` | `aggregate` |
| `auto_number` | `type` `name` | `style.rules` |
| `attachment` / `location` / `checkbox` | `type` `name` | 无 |
| `button` | `type` `name` | 无;工作流绑定另走 `+button-bind` |

所有类型都可额外传 `description`;上表的“常见补充字段”只列类型特有配置。

Expand Down Expand Up @@ -510,14 +511,30 @@
{ "type": "checkbox", "name": "完成" }
```

### 3.13 button

按钮字段只定义字段本身,不在字段 JSON 里写 `workflow_id`。

```json
{ "type": "button", "name": "同步线索" }
```

完整工作流是三步:

1. 用 `+workflow-create` 创建 `ButtonTrigger` 工作流,拿到 `wkf...`。
2. 用 `+field-create` 创建 `type:"button"` 字段,拿到 `fld...`。
3. 用 `+button-bind --base-token <base_token> --table-id <table_id> --field-id <field_id> --workflow-id <wkf...>` 建立绑定。

查询和解除绑定分别用 `+button-get`、`+button-unbind`。`+field-update` 不负责绑定或解绑工作流。

## 4. 创建与更新

- `+field-create`:按目标字段配置直接构造 `--json`。
- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes`。当 `type` 是 `auto_number` 时,更新编号规则本身就会把新规则应用到已有编号,无需额外参数,也不要在 JSON 里塞额外的底层实现参数。

## 5. 暂不支持字段

Object(对象字段)、Button(按钮字段)、Stage(流程字段)暂时都没有被 CLI 支持。这些字段会展示为 `not_support` 字段并被保护:不允许修改,不允许读取内容。
Object(对象字段)、Stage(流程字段)暂时没有被 CLI 支持。这些字段会展示为 `not_support` 字段并被保护:不允许修改,不允许读取内容。

遇到暂不支持的字段类型时,直接说明 Base CLI 当前不支持并停止;不要猜测未注册的字段 JSON、service 或 schema,也不要用其他字段类型冒充目标能力。

Expand Down
Loading