Skip to content
Merged
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
122 changes: 95 additions & 27 deletions shortcuts/base/base_execute_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -827,28 +827,6 @@ func TestBaseTableExecuteReadAndDelete(t *testing.T) {
}

func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Run("list", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"records": map[string]interface{}{
"schema": []interface{}{"Name", "Age"},
"record_ids": []interface{}{"rec_1"},
"rows": []interface{}{[]interface{}{"Alice", 18}},
}},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"records"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})

t.Run("list with fields and view", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Expand All @@ -864,7 +842,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--limit", "1", "--field-id", "Name", "--field-id", "Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
Expand All @@ -887,15 +865,15 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-id", "A,B", "--field-id", "C", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"A,B"`) || !strings.Contains(got, `"rec_json_fields"`) {
t.Fatalf("stdout=%s", got)
}
})

t.Run("list new shape", func(t *testing.T) {
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
Expand All @@ -904,20 +882,62 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"field_id_list": []interface{}{"fld_name", "fld_age"},
"record_id_list": []interface{}{"rec_2"},
"data": []interface{}{[]interface{}{"Bob", 20}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Bob"`) || !strings.Contains(got, `"rec_2"`) {
t.Fatalf("stdout=%s", got)
}
})

t.Run("list markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=2&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"field_id_list": []interface{}{"fld_name", "fld_age"},
"record_id_list": []interface{}{"rec_1", "rec_2"},
"data": []interface{}{
[]interface{}{"Alice", 18},
[]interface{}{"Bob", 20},
},
"has_more": false,
"query_context": map[string]interface{}{
"record_scope": "all_records",
"field_scope": "selected_fields",
},
"ignored_fields": []interface{}{"Formula"},
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "2", "--field-id", "Name", "--field-id", "Age"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"`_record_id` is metadata for record operations, not a table field.",
"| _record_id | Name | Age |",
"| rec_1 | Alice | 18 |",
"Meta: count=2; has_more=false; record_scope=all_records; field_scope=selected_fields; ignored_fields=1",
"Ignored fields: Formula",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})

t.Run("search", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
searchStub := &httpmock.Stub{
Expand Down Expand Up @@ -948,6 +968,7 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"view_id":"vew_x","keyword":"Created","search_fields":["Title","fld_owner"],"select_fields":["Title","fld_owner"],"offset":0,"limit":2}`,
"--format", "json",
},
factory,
stdout,
Expand All @@ -968,6 +989,53 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})

t.Run("search markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Title", "Owner"},
"field_id_list": []interface{}{"fld_title", "fld_owner"},
"record_id_list": []interface{}{"rec_1"},
"data": []interface{}{[]interface{}{"Created by AI", "Alice"}},
"has_more": false,
"query_context": map[string]interface{}{
"record_scope": "view_filtered_records",
"field_scope": "selected_fields",
"search_scope": "fld_title(Title)",
},
},
},
})
if err := runShortcut(
t,
BaseRecordSearch,
[]string{
"+record-search",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--json", `{"keyword":"Created","search_fields":["Title"],"select_fields":["Title","Owner"],"limit":2}`,
},
factory,
stdout,
); err != nil {
t.Fatalf("err=%v", err)
}
got := stdout.String()
for _, want := range []string{
"| _record_id | Title | Owner |",
"| rec_1 | Created by AI | Alice |",
"Meta: count=1; has_more=false; record_scope=view_filtered_records; field_scope=selected_fields; search_scope=fld_title(Title)",
} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
})

t.Run("list legacy fields flag rejected", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
Expand Down Expand Up @@ -1674,7 +1742,7 @@ func TestBaseRecordExecuteListWithViewPagination(t *testing.T) {
}, "total": 201},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1"}, factory, stdout); err != nil {
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--offset", "200", "--limit", "1", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_last"`) || !strings.Contains(got, `"total": 201`) {
Expand Down
2 changes: 1 addition & 1 deletion shortcuts/base/base_form_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ var BaseFormDelete = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},
Expand Down
2 changes: 1 addition & 1 deletion shortcuts/base/base_form_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ var BaseFormGet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},
Expand Down
2 changes: 1 addition & 1 deletion shortcuts/base/base_form_questions_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var BaseFormQuestionsList = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "base-token", Desc: "Base app token (base_token)", Required: true},
baseTokenFlag(true),
{Name: "table-id", Desc: "table ID", Required: true},
{Name: "form-id", Desc: "form ID", Required: true},
},
Expand Down
96 changes: 96 additions & 0 deletions shortcuts/base/base_shortcuts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,102 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
}
}

func TestBaseRecordReadHelpGuidesAgents(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
wantHelp []string
wantTips []string
}{
{
name: "record list",
shortcut: BaseRecordList,
wantHelp: []string{
"field ID or name to include; repeat to project only needed fields",
"view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view",
"pagination size, range 1-200",
"output format: markdown (default) | json",
},
wantTips: []string{
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --limit 50",
"lark-cli base +record-list --base-token <base_token> --table-id <table_id> --field-id Name --field-id Status --limit 50",
"Default output is markdown",
"Use --field-id repeatedly to keep output small",
"Use --view-id when the user asks for a specific view or after creating a temporary filtered/sorted view",
"lark-base record read SOP",
},
},
{
name: "record search",
shortcut: BaseRecordSearch,
wantHelp: []string{
"requires keyword/search_fields",
"optional select_fields/view_id/offset/limit",
"output format: markdown (default) | json",
},
wantTips: []string{
`lark-cli base +record-search --base-token <base_token> --table-id <table_id> --json`,
`"select_fields":["Name","Status"]`,
`JSON shape: {"keyword":"<text>","search_fields":["<field_id_or_name>"]`,
"search_fields length 1-20",
"limit range 1-200 defaults to 10",
"view_id scopes search to records in that view",
"Default output is markdown",
"only for keyword search",
"lark-base record read SOP",
},
},
{
name: "record get",
shortcut: BaseRecordGet,
wantHelp: []string{
"record ID",
},
wantTips: []string{
"lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"record_id is already known",
"lark-base record read SOP",
},
},
}

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]

help := cmd.Flags().FlagUsages()
for _, want := range tt.wantHelp {
if !strings.Contains(help, want) {
t.Fatalf("flag help missing %q:\n%s", want, help)
}
}
assertHelpOrder(t, help, "base token", "output format")
assertHelpOrder(t, help, "table ID", "output format")

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)
}
}
})
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func assertHelpOrder(t *testing.T, help string, before string, after string) {
t.Helper()
beforeIndex := strings.Index(help, before)
afterIndex := strings.Index(help, after)
if beforeIndex < 0 || afterIndex < 0 {
return
}
if beforeIndex > afterIndex {
t.Fatalf("flag help order mismatch: %q should appear before %q:\n%s", before, after, help)
}
}

func TestBaseFieldValidate(t *testing.T) {
ctx := context.Background()
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
Expand Down
10 changes: 10 additions & 0 deletions shortcuts/base/help.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package base

import "github.com/spf13/cobra"

func preserveFlagOrder(cmd *cobra.Command) {
cmd.Flags().SortFlags = false
}
9 changes: 9 additions & 0 deletions shortcuts/base/record_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"

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

var BaseRecordGet = common.Shortcut{
Expand All @@ -21,7 +22,15 @@ var BaseRecordGet = common.Shortcut{
tableRefFlag(true),
recordRefFlag(true),
},
Tips: []string{
"Example: lark-cli base +record-get --base-token <base_token> --table-id <table_id> --record-id <record_id>",
"Use +record-get when record_id is already known; otherwise use +record-search or +record-list.",
"Agent hint: follow the lark-base record read SOP for record read routing.",
},
DryRun: dryRunRecordGet,
PostMount: func(cmd *cobra.Command) {
preserveFlagOrder(cmd)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordGet(runtime)
},
Expand Down
Loading
Loading