diff --git a/shortcuts/minutes/minutes_search.go b/shortcuts/minutes/minutes_search.go
new file mode 100644
index 000000000..832a77c9e
--- /dev/null
+++ b/shortcuts/minutes/minutes_search.go
@@ -0,0 +1,347 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package minutes
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+
+ "github.com/larksuite/cli/internal/output"
+ "github.com/larksuite/cli/shortcuts/common"
+)
+
+const (
+ defaultMinutesSearchPageSize = 15
+ maxMinutesSearchPageSize = 30
+ maxMinutesSearchQueryLen = 50
+)
+
+// parseTimeRange normalizes --start and --end into RFC3339 timestamps.
+func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) {
+ start := strings.TrimSpace(runtime.Str("start"))
+ end := strings.TrimSpace(runtime.Str("end"))
+ if start == "" && end == "" {
+ return "", "", nil
+ }
+
+ var startTime, endTime string
+ if start != "" {
+ parsed, err := toRFC3339(start)
+ if err != nil {
+ return "", "", output.ErrValidation("--start: %v", err)
+ }
+ startTime = parsed
+ }
+ if end != "" {
+ parsed, err := toRFC3339(end, "end")
+ if err != nil {
+ return "", "", output.ErrValidation("--end: %v", err)
+ }
+ endTime = parsed
+ }
+ if startTime != "" && endTime != "" {
+ st, err := time.Parse(time.RFC3339, startTime)
+ if err != nil {
+ return "", "", fmt.Errorf("parse normalized --start: %w", err)
+ }
+ et, err := time.Parse(time.RFC3339, endTime)
+ if err != nil {
+ return "", "", fmt.Errorf("parse normalized --end: %w", err)
+ }
+ if st.After(et) {
+ return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
+ }
+ }
+ return startTime, endTime, nil
+}
+
+// toRFC3339 converts a supported CLI time input into an RFC3339 timestamp.
+func toRFC3339(input string, hint ...string) (string, error) {
+ ts, err := common.ParseTime(input, hint...)
+ if err != nil {
+ return "", err
+ }
+ sec, err := strconv.ParseInt(ts, 10, 64)
+ if err != nil {
+ return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
+ }
+ return time.Unix(sec, 0).Format(time.RFC3339), nil
+}
+
+// resolveUserIDs expands special user identifiers and removes duplicates.
+func resolveUserIDs(flagName string, ids []string, runtime *common.RuntimeContext) ([]string, error) {
+ if len(ids) == 0 {
+ return nil, nil
+ }
+ currentUserID := runtime.UserOpenId()
+ seen := make(map[string]struct{}, len(ids))
+ out := make([]string, 0, len(ids))
+ for _, id := range ids {
+ if strings.EqualFold(id, "me") {
+ if currentUserID == "" {
+ return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
+ }
+ id = currentUserID
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ return out, nil
+}
+
+// buildTimeFilter builds the create_time filter block for the API request.
+func buildTimeFilter(startTime, endTime string) map[string]interface{} {
+ if startTime == "" && endTime == "" {
+ return nil
+ }
+ timeRange := map[string]interface{}{}
+ if startTime != "" {
+ timeRange["start_time"] = startTime
+ }
+ if endTime != "" {
+ timeRange["end_time"] = endTime
+ }
+ return timeRange
+}
+
+// buildMinutesSearchFilter builds the filter object for the API request body.
+func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
+ filter := map[string]interface{}{}
+
+ ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
+ if err != nil {
+ return nil, err
+ }
+ if len(ownerIDs) > 0 {
+ filter["owner_ids"] = ownerIDs
+ }
+
+ participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
+ if err != nil {
+ return nil, err
+ }
+ if len(participantIDs) > 0 {
+ filter["participant_ids"] = participantIDs
+ }
+
+ if timeRange := buildTimeFilter(startTime, endTime); timeRange != nil {
+ filter["create_time"] = timeRange
+ }
+
+ if len(filter) == 0 {
+ return nil, nil
+ }
+ return filter, nil
+}
+
+// buildMinutesSearchBody builds the POST body for the minutes search API.
+func buildMinutesSearchBody(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
+ body := map[string]interface{}{}
+
+ if q := strings.TrimSpace(runtime.Str("query")); q != "" {
+ body["query"] = q
+ }
+
+ filter, err := buildMinutesSearchFilter(runtime, startTime, endTime)
+ if err != nil {
+ return nil, err
+ }
+ if filter != nil {
+ body["filter"] = filter
+ }
+
+ return body, nil
+}
+
+// buildMinutesSearchParams builds the query parameters for the search request.
+func buildMinutesSearchParams(runtime *common.RuntimeContext) map[string]interface{} {
+ params := map[string]interface{}{}
+
+ pageSize := strings.TrimSpace(runtime.Str("page-size"))
+ if pageSize == "" {
+ pageSize = fmt.Sprintf("%d", defaultMinutesSearchPageSize)
+ }
+ params["page_size"] = pageSize
+
+ if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
+ params["page_token"] = pageToken
+ }
+
+ return params
+}
+
+// minuteSearchItems extracts the result items from the API response payload.
+func minuteSearchItems(data map[string]interface{}) []interface{} {
+ return common.GetSlice(data, "items")
+}
+
+// minuteSearchToken extracts the minute token from a search result item.
+func minuteSearchToken(item map[string]interface{}) string {
+ return common.GetString(item, "token")
+}
+
+// minuteSearchDisplayInfo extracts the display_info field from a search result item.
+func minuteSearchDisplayInfo(item map[string]interface{}) string {
+ return common.GetString(item, "display_info")
+}
+
+// minuteSearchDescription extracts the description field from a search result item.
+func minuteSearchDescription(item map[string]interface{}) string {
+ meta := common.GetMap(item, "meta_data")
+ return common.GetString(meta, "description")
+}
+
+// minuteSearchAppLink extracts the app link from a search result item.
+func minuteSearchAppLink(item map[string]interface{}) string {
+ meta := common.GetMap(item, "meta_data")
+ return common.GetString(meta, "app_link")
+}
+
+// minuteSearchAvatar extracts the avatar URL from a search result item.
+func minuteSearchAvatar(item map[string]interface{}) string {
+ meta := common.GetMap(item, "meta_data")
+ return common.GetString(meta, "avatar")
+}
+
+// buildMinuteSearchRows converts API items into pretty output rows.
+func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
+ rows := make([]map[string]interface{}, 0, len(items))
+ for _, raw := range items {
+ item, _ := raw.(map[string]interface{})
+ if item == nil {
+ continue
+ }
+ rows = append(rows, map[string]interface{}{
+ "token": minuteSearchToken(item),
+ "display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40),
+ "description": common.TruncateStr(minuteSearchDescription(item), 40),
+ "app_link": common.TruncateStr(minuteSearchAppLink(item), 80),
+ "avatar": common.TruncateStr(minuteSearchAvatar(item), 80),
+ })
+ }
+ return rows
+}
+
+// MinutesSearch searches minutes by keyword, owners, participants, and time range.
+var MinutesSearch = common.Shortcut{
+ Service: "minutes",
+ Command: "+search",
+ Description: "Search minutes by keyword, owners, participants, and time range",
+ Risk: "read",
+ Scopes: []string{"minutes:minutes.search:read"},
+ AuthTypes: []string{"user"},
+ HasFormat: true,
+ Flags: []common.Flag{
+ {Name: "query", Desc: "search keyword"},
+ {Name: "owner-ids", Desc: "owner open_id list, comma-separated (use \"me\" for current user)"},
+ {Name: "participant-ids", Desc: "participant open_id list, comma-separated (use \"me\" for current user)"},
+ {Name: "start", Desc: "time lower bound (ISO 8601 or YYYY-MM-DD)"},
+ {Name: "end", Desc: "time upper bound (ISO 8601 or YYYY-MM-DD)"},
+ {Name: "page-token", Desc: "page token for next page"},
+ {Name: "page-size", Default: "15", Desc: "page size, 1-30 (default 15)"},
+ },
+ Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ if _, _, err := parseTimeRange(runtime); err != nil {
+ return err
+ }
+ if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen {
+ return output.ErrValidation("--query: length must be between 1 and 50 characters")
+ }
+ if _, err := common.ValidatePageSize(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
+ return err
+ }
+ ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
+ if err != nil {
+ return err
+ }
+ for _, id := range ownerIDs {
+ if _, err := common.ValidateUserID(id); err != nil {
+ return err
+ }
+ }
+ participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
+ if err != nil {
+ return err
+ }
+ for _, id := range participantIDs {
+ if _, err := common.ValidateUserID(id); err != nil {
+ return err
+ }
+ }
+ for _, flag := range []string{"query", "owner-ids", "participant-ids", "start", "end"} {
+ if strings.TrimSpace(runtime.Str(flag)) != "" {
+ return nil
+ }
+ }
+ return common.FlagErrorf("specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
+ },
+ DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
+ startTime, endTime, err := parseTimeRange(runtime)
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
+ params := buildMinutesSearchParams(runtime)
+ body, err := buildMinutesSearchBody(runtime, startTime, endTime)
+ if err != nil {
+ return common.NewDryRunAPI().Set("error", err.Error())
+ }
+ dryRun := common.NewDryRunAPI().
+ POST("/open-apis/minutes/v1/minutes/search")
+ if len(params) > 0 {
+ dryRun.Params(params)
+ }
+ return dryRun.Body(body)
+ },
+ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
+ startTime, endTime, err := parseTimeRange(runtime)
+ if err != nil {
+ return err
+ }
+ body, err := buildMinutesSearchBody(runtime, startTime, endTime)
+ if err != nil {
+ return err
+ }
+
+ data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
+ if err != nil {
+ return err
+ }
+ if data == nil {
+ data = map[string]interface{}{}
+ }
+
+ items := minuteSearchItems(data)
+ hasMore, _ := data["has_more"].(bool)
+ pageToken, _ := data["page_token"].(string)
+ rows := buildMinuteSearchRows(items)
+
+ outData := map[string]interface{}{
+ "items": items,
+ "total": data["total"],
+ "has_more": data["has_more"],
+ "page_token": data["page_token"],
+ }
+
+ runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
+ if len(rows) == 0 {
+ fmt.Fprintln(w, "No minutes.")
+ return
+ }
+ output.PrintTable(w, rows)
+ })
+ if hasMore && runtime.Format != "json" && runtime.Format != "" {
+ fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
+ }
+ return nil
+ },
+}
diff --git a/shortcuts/minutes/minutes_search_test.go b/shortcuts/minutes/minutes_search_test.go
new file mode 100644
index 000000000..f7a28415d
--- /dev/null
+++ b/shortcuts/minutes/minutes_search_test.go
@@ -0,0 +1,691 @@
+// Copyright (c) 2026 Lark Technologies Pte. Ltd.
+// SPDX-License-Identifier: MIT
+
+package minutes
+
+import (
+ "context"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/larksuite/cli/internal/cmdutil"
+ "github.com/larksuite/cli/internal/core"
+ "github.com/larksuite/cli/internal/httpmock"
+ "github.com/larksuite/cli/shortcuts/common"
+ "github.com/spf13/cobra"
+)
+
+// newMinutesSearchTestCommand builds a command with the flags used by minutes search tests.
+func newMinutesSearchTestCommand() *cobra.Command {
+ cmd := &cobra.Command{Use: "test"}
+ cmd.Flags().String("query", "", "")
+ cmd.Flags().String("owner-ids", "", "")
+ cmd.Flags().String("participant-ids", "", "")
+ cmd.Flags().String("start", "", "")
+ cmd.Flags().String("end", "", "")
+ cmd.Flags().String("page-token", "", "")
+ cmd.Flags().String("page-size", "15", "")
+ return cmd
+}
+
+// configWithoutUserOpenID returns a test config without a resolvable user open_id.
+func configWithoutUserOpenID() *core.CliConfig {
+ cfg := defaultConfig()
+ cfg.UserOpenId = ""
+ return cfg
+}
+
+// TestMinutesSearchParseTimeRange verifies valid time inputs are normalized.
+func TestMinutesSearchParseTimeRange(t *testing.T) {
+ t.Parallel()
+
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set("start", "2026-03-24")
+ _ = cmd.Flags().Set("end", "2026-03-25")
+
+ runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
+ start, end, err := parseTimeRange(runtime)
+ if err != nil {
+ t.Fatalf("parseTimeRange() unexpected error: %v", err)
+ }
+ if start == "" || end == "" {
+ t.Fatalf("expected non-empty start/end, got %q %q", start, end)
+ }
+}
+
+// TestMinutesSearchParseTimeRangeErrors verifies invalid time inputs return validation errors.
+func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ start string
+ end string
+ wantMessage string
+ }{
+ {name: "invalid start", start: "bad-start", wantMessage: "--start:"},
+ {name: "invalid end", end: "bad-end", wantMessage: "--end:"},
+ {name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ cmd := newMinutesSearchTestCommand()
+ if tt.start != "" {
+ _ = cmd.Flags().Set("start", tt.start)
+ }
+ if tt.end != "" {
+ _ = cmd.Flags().Set("end", tt.end)
+ }
+
+ _, _, err := parseTimeRange(common.TestNewRuntimeContext(cmd, defaultConfig()))
+ if err == nil {
+ t.Fatal("expected parseTimeRange error")
+ }
+ if !strings.Contains(err.Error(), tt.wantMessage) {
+ t.Fatalf("error = %v, want %q", err, tt.wantMessage)
+ }
+ })
+ }
+}
+
+// TestBuildMinutesSearchParams verifies request params and body fields are assembled correctly.
+func TestBuildMinutesSearchParams(t *testing.T) {
+ t.Parallel()
+
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set("query", "budget")
+ _ = cmd.Flags().Set("owner-ids", "ou_owner,ou_owner_2")
+ _ = cmd.Flags().Set("participant-ids", "ou_c")
+ _ = cmd.Flags().Set("page-size", "5")
+ _ = cmd.Flags().Set("page-token", "next_page")
+
+ runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
+ params := buildMinutesSearchParams(runtime)
+ body, err := buildMinutesSearchBody(runtime, "2026-03-24T00:00:00Z", "2026-03-25T00:00:00Z")
+ if err != nil {
+ t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err)
+ }
+
+ if got, _ := params["page_size"].(string); got != "5" {
+ t.Fatalf("page_size = %q, want 5", got)
+ }
+ if got, _ := params["page_token"].(string); got != "next_page" {
+ t.Fatalf("page_token = %q, want next_page", got)
+ }
+ if body["query"] != "budget" {
+ t.Fatalf("body.query = %v, want budget", body["query"])
+ }
+ filter, _ := body["filter"].(map[string]interface{})
+ if filter == nil {
+ t.Fatalf("body.filter = nil, want filter object")
+ }
+ owners, _ := filter["owner_ids"].([]string)
+ if len(owners) != 2 || owners[0] != "ou_owner" || owners[1] != "ou_owner_2" {
+ t.Fatalf("owner_ids = %v, want [ou_owner ou_owner_2]", filter["owner_ids"])
+ }
+ participants, _ := filter["participant_ids"].([]string)
+ if len(participants) != 1 || participants[0] != "ou_c" {
+ t.Fatalf("participant_ids = %v, want [ou_c]", filter["participant_ids"])
+ }
+ createTime, _ := filter["create_time"].(map[string]interface{})
+ if createTime == nil {
+ t.Fatalf("create_time = nil, want time range")
+ }
+ if createTime["start_time"] != "2026-03-24T00:00:00Z" {
+ t.Fatalf("start_time = %v", createTime["start_time"])
+ }
+ if createTime["end_time"] != "2026-03-25T00:00:00Z" {
+ t.Fatalf("end_time = %v", createTime["end_time"])
+ }
+}
+
+// TestBuildMinutesSearchParamsDefaultPageSize verifies the default page size is applied.
+func TestBuildMinutesSearchParamsDefaultPageSize(t *testing.T) {
+ t.Parallel()
+
+ cmd := newMinutesSearchTestCommand()
+
+ params := buildMinutesSearchParams(common.TestNewRuntimeContext(cmd, defaultConfig()))
+ if got, _ := params["page_size"].(string); got != "15" {
+ t.Fatalf("page_size = %q, want 15", got)
+ }
+}
+
+// TestResolveUserIDs verifies me expansion, deduplication, and nil handling.
+func TestResolveUserIDs(t *testing.T) {
+ t.Parallel()
+
+ cmd := &cobra.Command{Use: "test"}
+ runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
+
+ got, err := resolveUserIDs("--owner-ids", []string{"me"}, runtime)
+ if err != nil {
+ t.Fatalf("resolveUserIDs([me]) unexpected error: %v", err)
+ }
+ if len(got) != 1 || got[0] != "ou_testuser" {
+ t.Fatalf("resolveUserIDs([me]) = %v, want [ou_testuser]", got)
+ }
+
+ got, err = resolveUserIDs("--owner-ids", []string{"ou_other", "me", "Me"}, runtime)
+ if err != nil {
+ t.Fatalf("resolveUserIDs([ou_other, me, Me]) unexpected error: %v", err)
+ }
+ if len(got) != 2 || got[0] != "ou_other" || got[1] != "ou_testuser" {
+ t.Fatalf("resolveUserIDs([ou_other, me, Me]) = %v, want [ou_other ou_testuser]", got)
+ }
+
+ got, err = resolveUserIDs("--owner-ids", nil, runtime)
+ if err != nil {
+ t.Fatalf("resolveUserIDs(nil) unexpected error: %v", err)
+ }
+ if got != nil {
+ t.Fatalf("resolveUserIDs(nil) = %v, want nil", got)
+ }
+}
+
+// TestBuildTimeFilter verifies time filters are only populated for provided bounds.
+func TestBuildTimeFilter(t *testing.T) {
+ t.Parallel()
+
+ if got := buildTimeFilter("", ""); got != nil {
+ t.Fatalf("buildTimeFilter('', '') = %v, want nil", got)
+ }
+ if got := buildTimeFilter("2026-03-24T00:00:00Z", ""); got["start_time"] != "2026-03-24T00:00:00Z" {
+ t.Fatalf("start_time = %v", got["start_time"])
+ }
+ if got := buildTimeFilter("", "2026-03-25T00:00:00Z"); got["end_time"] != "2026-03-25T00:00:00Z" {
+ t.Fatalf("end_time = %v", got["end_time"])
+ }
+}
+
+// TestMinutesSearchValidationMeOwnerID verifies owner-ids accepts me when open_id is available.
+func TestMinutesSearchValidationMeOwnerID(t *testing.T) {
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set("owner-ids", "me")
+
+ runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
+ err := MinutesSearch.Validate(context.Background(), runtime)
+ if err != nil {
+ t.Fatalf("expected no error for --owner-ids me, got: %v", err)
+ }
+}
+
+// TestMinutesSearchValidationMeRequiresResolvableUser verifies me fails without a resolvable open_id.
+func TestMinutesSearchValidationMeRequiresResolvableUser(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ flag string
+ }{
+ {name: "owner ids", flag: "owner-ids"},
+ {name: "participant ids", flag: "participant-ids"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set(tt.flag, "me")
+
+ runtime := common.TestNewRuntimeContext(cmd, configWithoutUserOpenID())
+ err := MinutesSearch.Validate(context.Background(), runtime)
+ if err == nil {
+ t.Fatal("expected validation error for unresolved me")
+ }
+ if !strings.Contains(err.Error(), "resolvable open_id") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ })
+ }
+}
+
+// TestBuildMinutesSearchFilterMeExpansion verifies me is expanded inside the request filter.
+func TestBuildMinutesSearchFilterMeExpansion(t *testing.T) {
+ t.Parallel()
+
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set("owner-ids", "me,ou_other")
+ _ = cmd.Flags().Set("participant-ids", "me")
+
+ runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
+ body, err := buildMinutesSearchBody(runtime, "", "")
+ if err != nil {
+ t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err)
+ }
+
+ filter, _ := body["filter"].(map[string]interface{})
+ if filter == nil {
+ t.Fatal("body.filter = nil, want filter object")
+ }
+ owners, _ := filter["owner_ids"].([]string)
+ if len(owners) != 2 || owners[0] != "ou_testuser" || owners[1] != "ou_other" {
+ t.Fatalf("owner_ids = %v, want [ou_testuser ou_other]", owners)
+ }
+ participants, _ := filter["participant_ids"].([]string)
+ if len(participants) != 1 || participants[0] != "ou_testuser" {
+ t.Fatalf("participant_ids = %v, want [ou_testuser]", participants)
+ }
+}
+
+// TestMinuteSearchItems verifies items extraction from the search response payload.
+func TestMinuteSearchItems(t *testing.T) {
+ t.Parallel()
+
+ items := minuteSearchItems(map[string]interface{}{
+ "items": []interface{}{map[string]interface{}{"minute_token": "tok_1"}},
+ })
+ if len(items) != 1 {
+ t.Fatalf("minuteSearchItems() len = %d, want 1", len(items))
+ }
+
+ if got := minuteSearchItems(map[string]interface{}{}); got != nil {
+ t.Fatalf("minuteSearchItems() = %v, want nil", got)
+ }
+}
+
+// TestMinutesSearchValidationNoFilter verifies at least one filter is required.
+func TestMinutesSearchValidationNoFilter(t *testing.T) {
+ f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ err := mountAndRun(t, MinutesSearch, []string{"+search", "--as", "user"}, f, nil)
+ if err == nil {
+ t.Fatal("expected validation error for empty filters")
+ }
+ if !strings.Contains(err.Error(), "specify at least one") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+// TestMinutesSearchValidationInvalidParticipantID verifies participant IDs must be valid open_ids.
+func TestMinutesSearchValidationInvalidParticipantID(t *testing.T) {
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set("participant-ids", "user_123")
+
+ runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
+ err := MinutesSearch.Validate(context.Background(), runtime)
+ if err == nil {
+ t.Fatal("expected invalid user ID error")
+ }
+}
+
+// TestMinutesSearchValidationInvalidOwnerID verifies owner IDs must be valid open_ids.
+func TestMinutesSearchValidationInvalidOwnerID(t *testing.T) {
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set("owner-ids", "user_123")
+
+ runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
+ err := MinutesSearch.Validate(context.Background(), runtime)
+ if err == nil {
+ t.Fatal("expected invalid owner ID error")
+ }
+}
+
+// TestMinutesSearchValidationQueryTooLong verifies overly long queries are rejected.
+func TestMinutesSearchValidationQueryTooLong(t *testing.T) {
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set("query", strings.Repeat("a", 51))
+
+ runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
+ err := MinutesSearch.Validate(context.Background(), runtime)
+ if err == nil {
+ t.Fatal("expected query length error")
+ }
+ if !strings.Contains(err.Error(), "length must be between 1 and 50 characters") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+// TestMinutesSearchValidationMaxPageSize30 verifies the maximum allowed page size passes validation.
+func TestMinutesSearchValidationMaxPageSize30(t *testing.T) {
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set("query", "budget")
+ _ = cmd.Flags().Set("page-size", "30")
+
+ runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
+ err := MinutesSearch.Validate(context.Background(), runtime)
+ if err != nil {
+ t.Fatalf("expected no error for --page-size 30, got: %v", err)
+ }
+}
+
+// TestMinutesSearchValidationPageSizeAboveMax verifies page sizes above the limit are rejected.
+func TestMinutesSearchValidationPageSizeAboveMax(t *testing.T) {
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set("query", "budget")
+ _ = cmd.Flags().Set("page-size", "31")
+
+ runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
+ err := MinutesSearch.Validate(context.Background(), runtime)
+ if err == nil {
+ t.Fatal("expected validation error for --page-size 31")
+ }
+ if !strings.Contains(err.Error(), "--page-size") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+// TestMinutesSearchValidationTimeErrors verifies time parsing failures surface through validation.
+func TestMinutesSearchValidationTimeErrors(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ start string
+ end string
+ wantMessage string
+ }{
+ {name: "invalid start", start: "bad-start", wantMessage: "--start:"},
+ {name: "invalid end", end: "bad-end", wantMessage: "--end:"},
+ {name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ cmd := newMinutesSearchTestCommand()
+ _ = cmd.Flags().Set("query", "budget")
+ if tt.start != "" {
+ _ = cmd.Flags().Set("start", tt.start)
+ }
+ if tt.end != "" {
+ _ = cmd.Flags().Set("end", tt.end)
+ }
+
+ err := MinutesSearch.Validate(context.Background(), common.TestNewRuntimeContext(cmd, defaultConfig()))
+ if err == nil {
+ t.Fatal("expected validation error")
+ }
+ if !strings.Contains(err.Error(), tt.wantMessage) {
+ t.Fatalf("error = %v, want %q", err, tt.wantMessage)
+ }
+ })
+ }
+}
+
+// TestMinutesSearchDryRun verifies dry-run output includes the expected API request details.
+func TestMinutesSearchDryRun(t *testing.T) {
+ f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
+ err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "ou_owner,ou_owner_2", "--dry-run", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/search") {
+ t.Fatalf("dry-run should show API path, got: %s", stdout.String())
+ }
+ if !strings.Contains(stdout.String(), "\"method\": \"POST\"") {
+ t.Fatalf("dry-run should use POST, got: %s", stdout.String())
+ }
+ if !strings.Contains(stdout.String(), "\"query\": \"budget\"") {
+ t.Fatalf("dry-run should show query in body, got: %s", stdout.String())
+ }
+ if !strings.Contains(stdout.String(), "\"owner_ids\": [") || !strings.Contains(stdout.String(), "\"ou_owner\"") {
+ t.Fatalf("dry-run should show owner_ids in filter, got: %s", stdout.String())
+ }
+}
+
+// TestMinutesSearchExecuteRendersRowsAndMoreHint verifies pretty output renders rows and pagination hints.
+func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ searchStub := &httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/minutes/v1/minutes/search",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{
+ "token": "minute_1",
+ "display_info": "周会摘要",
+ "meta_data": map[string]interface{}{
+ "description": "周会纪要",
+ "app_link": "https://meetings.feishu.cn/minutes/obcn123",
+ "avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
+ },
+ },
+ },
+ "total": 1,
+ "has_more": true,
+ "page_token": "next_token",
+ },
+ },
+ }
+ reg.Register(searchStub)
+
+ err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "me", "--format", "pretty", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ reg.Verify(t)
+
+ var body map[string]interface{}
+ if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
+ t.Fatalf("unmarshal request body: %v", err)
+ }
+ if body["query"] != "budget" {
+ t.Fatalf("request query = %v, want budget", body["query"])
+ }
+ filter, _ := body["filter"].(map[string]interface{})
+ if filter == nil {
+ t.Fatalf("request filter = %v, want object", body["filter"])
+ }
+ owners, _ := filter["owner_ids"].([]interface{})
+ if len(owners) != 1 || owners[0] != "ou_testuser" {
+ t.Fatalf("request owner_ids = %v, want [ou_testuser]", filter["owner_ids"])
+ }
+
+ out := stdout.String()
+ for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("output missing %q, got: %s", want, out)
+ }
+ }
+}
+
+// TestMinutesSearchExecuteNoMinutes verifies empty results render the no-data message.
+func TestMinutesSearchExecuteNoMinutes(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/minutes/v1/minutes/search",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{},
+ "total": 0,
+ "has_more": false,
+ "page_token": "",
+ },
+ },
+ })
+
+ err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "pretty", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ reg.Verify(t)
+ if !strings.Contains(stdout.String(), "No minutes.") {
+ t.Fatalf("expected no minutes message, got: %s", stdout.String())
+ }
+}
+
+// TestMinutesSearchExecuteShowsPaginationHintForTableFormat verifies table output includes pagination hints.
+func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/minutes/v1/minutes/search",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{
+ map[string]interface{}{
+ "token": "minute_1",
+ "display_info": "周会摘要",
+ "meta_data": map[string]interface{}{
+ "description": "周会纪要",
+ "app_link": "https://meetings.feishu.cn/minutes/obcn123",
+ "avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
+ },
+ },
+ },
+ "total": 1,
+ "has_more": true,
+ "page_token": "next_token",
+ },
+ },
+ })
+
+ err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "table", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ reg.Verify(t)
+
+ out := stdout.String()
+ if !strings.Contains(out, "next_token") || !strings.Contains(out, "more available") {
+ t.Fatalf("expected pagination hint in table output, got: %s", out)
+ }
+}
+
+// TestMinutesSearchExecuteJSONCountUsesRenderedRows verifies JSON metadata counts rendered rows only.
+func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
+ t.Parallel()
+
+ f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
+ reg.Register(&httpmock.Stub{
+ Method: "POST",
+ URL: "/open-apis/minutes/v1/minutes/search",
+ Body: map[string]interface{}{
+ "code": 0,
+ "msg": "ok",
+ "data": map[string]interface{}{
+ "items": []interface{}{
+ nil,
+ map[string]interface{}{
+ "token": "minute_1",
+ "display_info": "周会摘要",
+ "meta_data": map[string]interface{}{
+ "description": "周会纪要",
+ },
+ },
+ },
+ "total": 2,
+ "has_more": false,
+ "page_token": "",
+ },
+ },
+ })
+
+ err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--as", "user"}, f, stdout)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ reg.Verify(t)
+
+ var envelope struct {
+ Meta struct {
+ Count int `json:"count"`
+ } `json:"meta"`
+ }
+ if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
+ t.Fatalf("failed to parse output: %v\nraw: %s", err, stdout.String())
+ }
+ if envelope.Meta.Count != 1 {
+ t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
+ }
+}
+
+// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.
+func TestMinuteSearchFieldExtractors(t *testing.T) {
+ t.Parallel()
+
+ item := map[string]interface{}{
+ "token": "minute_1",
+ "display_info": "周会摘要",
+ "meta_data": map[string]interface{}{
+ "description": "周会纪要",
+ "app_link": "https://meetings.feishu.cn/minutes/obcn123",
+ "avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
+ },
+ }
+
+ if got := minuteSearchToken(item); got != "minute_1" {
+ t.Fatalf("minuteSearchToken() = %q, want minute_1", got)
+ }
+ if got := minuteSearchDisplayInfo(item); got != "周会摘要" {
+ t.Fatalf("minuteSearchDisplayInfo() = %q", got)
+ }
+ if got := minuteSearchDescription(item); got != "周会纪要" {
+ t.Fatalf("minuteSearchDescription() = %q, want 周会纪要", got)
+ }
+ if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" {
+ t.Fatalf("minuteSearchAppLink() = %q", got)
+ }
+ if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" {
+ t.Fatalf("minuteSearchAvatar() = %q", got)
+ }
+}
+
+// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data.
+func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
+ t.Parallel()
+
+ item := map[string]interface{}{
+ "token": "minute_2",
+ "display_info": "回退摘要",
+ "meta_data": map[string]interface{}{
+ "description": "回退纪要",
+ "app_link": "https://meetings.feishu.cn/minutes/fallback",
+ "avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg",
+ },
+ }
+
+ if got := minuteSearchToken(item); got != "minute_2" {
+ t.Fatalf("minuteSearchToken() = %q, want minute_2", got)
+ }
+ if got := minuteSearchDescription(item); got != "回退纪要" {
+ t.Fatalf("minuteSearchDescription() = %q, want 回退纪要", got)
+ }
+ if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" {
+ t.Fatalf("minuteSearchAppLink() = %q", got)
+ }
+ if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" {
+ t.Fatalf("minuteSearchAvatar() = %q", got)
+ }
+}
+
+// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata.
+func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) {
+ t.Parallel()
+
+ item := map[string]interface{}{
+ "token": "minute_3",
+ "display_info": "无元信息摘要",
+ }
+
+ if got := minuteSearchToken(item); got != "minute_3" {
+ t.Fatalf("minuteSearchToken() = %q, want minute_3", got)
+ }
+ if got := minuteSearchDescription(item); got != "" {
+ t.Fatalf("minuteSearchDescription() = %q, want empty", got)
+ }
+ if got := minuteSearchAppLink(item); got != "" {
+ t.Fatalf("minuteSearchAppLink() = %q, want empty", got)
+ }
+ if got := minuteSearchAvatar(item); got != "" {
+ t.Fatalf("minuteSearchAvatar() = %q, want empty", got)
+ }
+}
diff --git a/shortcuts/minutes/shortcuts.go b/shortcuts/minutes/shortcuts.go
index 9c1431f29..9c0651e73 100644
--- a/shortcuts/minutes/shortcuts.go
+++ b/shortcuts/minutes/shortcuts.go
@@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all minutes shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
+ MinutesSearch,
MinutesDownload,
}
}
diff --git a/skills/lark-minutes/SKILL.md b/skills/lark-minutes/SKILL.md
index eb7962c8d..283621729 100644
--- a/skills/lark-minutes/SKILL.md
+++ b/skills/lark-minutes/SKILL.md
@@ -1,7 +1,7 @@
---
name: lark-minutes
version: 1.0.0
-description: "飞书妙记:获取妙记基础信息(标题、封面、时长)和相关的 AI 产物(总结、待办、章节),下载妙记音视频文件。飞书妙记的 URL 格式为: http(s):///minutes/"
+description: "飞书妙记:妙记相关基本功能。1.查询妙记列表(按关键词/所有者/参与者/时间范围);2.获取妙记基础信息(标题、封面、时长 等);3.下载妙记音视频文件;4.获取妙记相关 AI 产物(总结、待办、章节)。飞书妙记 URL 格式: http(s):///minutes/"
metadata:
requires:
bins: ["lark-cli"]
@@ -14,64 +14,84 @@ metadata:
## 核心概念
-- **妙记 Token(minute_token)**:妙记的唯一标识符。通常可从妙记的 URL 链接中提取(例如 `https://*.feishu.cn/minutes/obcnq3b9jl72l83w4f14xxxx` 中的最后一段字符串 `obcnq3b9jl72l83w4f14xxxx`)。
+- **妙记(Minutes)**:来源于飞书视频会议的录制产物或用户上传的音视频文件,通过 `minute_token` 标识。
+- **妙记 Token(minute\_token)**:妙记的唯一标识符,可从妙记 URL 末尾提取(例如 `https://*.feishu.cn/minutes/obcnq3b9jl72l83w4f14xxxx` 中的 `obcnq3b9jl72l83w4f14xxxx`)。如果 URL 中包含额外参数(如 `?xxx`),应截取路径最后一段。
-## 使用说明
+## 核心场景
-1. **提取 Token**:
- - 只有 `minute_token` 参数是必填的。
- - 如果 URL 中包含额外参数(如 `?xxx`),请截取路径部分的最后一段作为 token。
- - 示例:从 `https://domain.feishu.cn/minutes/obc123456?project=xxx` 中提取出 `obc123456`。
+### 1. 搜索妙记
-2. **获取妙记信息**:
- - 使用 `lark-cli schema minutes.minutes.get` 可以查看具体的返回值结构。
- - 返回的核心字段通常包含:
- - `title`:会议标题
- - `cover`:视频/音频封面 URL
- - `duration`:会议时长(毫秒)
- - `owner_id`:所有者 ID
- - `url`:妙记链接
+1. 当用户描述的是"我的妙记""包含某个关键词的妙记""某段时间内的妙记",优先使用 `minutes +search`。
+2. 仅支持使用关键词、时间段、参与者、所有者等筛选条件搜索妙记记录,对于不支持的筛选条件,需要提示用户。
+3. 搜索结果存在多条数据时,务必注意分页数据获取,不要遗漏任何妙记记录。
+4. 如果是会议的妙记,应优先使用 [vc +search](../lark-vc/references/lark-vc-search.md) 先定位会议,再按需通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`。
-## 典型场景
-### 妙记内容查询
+### 2. 查看妙记基础信息
-```bash
-# 首先查询妙记元信息(标题、时长、封面) → 用本 skill
-lark-cli minutes minutes get --params '{"minute_token": "obcn***************"}'
-
-# 查妙记关联的纪要产物:逐字稿、总结、待办、章节等 → 用 lark-cli vc +notes
-lark-cli vc +notes --minute-tokens obcnhijv43vq6bcsl5xasfb2
-```
-本 skill 仅提供妙记**基础元信息**查询(标题、封面、时长)。如需获取纪要**内容**(逐字稿、AI 总结、待办、章节),请使用 [lark-cli vc +notes](../lark-vc/references/lark-vc-notes.md):
+1. 当用户只需要确认某条妙记的标题、封面、时长、所有者、URL 等基础信息时,使用 `minutes minutes get`。
+2. 如果用户给的是妙记 URL,应先从 URL 末尾提取 `minute_token`,再调用 `minutes minutes get`。
+3. 用户意图不明确时,默认先给基础元信息,帮助确认是否命中目标妙记。
-- 用户未指定需要查询妙记的哪些内容时,默认查询基础元信息和相关联的纪要产物信息。
-- 用户未明确指定查看纪要产物(逐字稿、总结、待办、章节)时,向用户展示对应产物的链接即可,不需要直接读取产物内容。
+> 使用 `lark-cli schema minutes.minutes.get` 可查看完整返回值结构。核心字段包含:`title`(标题)、`cover`(封面 URL)、`duration`(时长,毫秒)、`owner_id`(所有者 ID)、`url`(妙记链接)。
-## Shortcuts(推荐优先使用)
+### 3. 下载妙记音视频文件
-Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]`)。有 Shortcut 的操作优先使用。
+1. 下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
+2. `minutes +download` 只负责音视频媒体文件。
+3. 用户只想拿可分享的下载地址时,使用 `--url-only`;用户要落地到本地文件时,直接下载。
-| Shortcut | 说明 |
-|----------|------|
-| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
+> **注意**:`+download` 只负责音视频媒体文件。如果用户需要的是逐字稿、总结、待办、章节等纪要内容,请使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)。
-### 妙记音视频下载
+### 4. 获取妙记的逐字稿、总结、待办、章节
-下载妙记音视频文件到本地,或获取有效期 1 天的下载链接。详见 [minutes +download](references/lark-minutes-download.md)。
+1. 当用户说"这个妙记的逐字稿""总结""待办""章节"时,**不属于本 skill**。
+2. 应使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md) 获取对应的纪要产物。
+3. 如果当前上下文中已有 `minute_token`,可直接传给 `vc +notes`;如果只有妙记 URL,先提取 `minute_token`。
```bash
-# 下载音视频文件到本地
-lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --output ./meeting.mp4
+# 通过 minute_token 获取纪要产物(逐字稿、总结、待办、章节)
+lark-cli vc +notes --minute-tokens
+```
-# 仅获取下载链接
-lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c --url-only
+> **跨 skill 路由**:逐字稿、AI 总结、待办、章节等纪要内容由 [lark-vc](../lark-vc/SKILL.md) 的 `+notes` 命令提供
-# 批量下载
-lark-cli minutes +download --minute-tokens obcnq3b9jl72l83w4f149w9c,obcnexa7814k4t41c446fzwj
+## 资源关系
+
+```text
+Minutes (妙记) ← minute_token 标识
+├── Metadata (标题、封面、时长、owner、url) → minutes minutes get
+└── MediaFile (音频/视频文件) → minutes +download
```
+> **能力边界**:`minutes` 负责 **搜索妙记、查看基础元信息、下载音视频文件**。
+>
+> **路由规则**:
+>
+> - 用户说"妙记列表 / 搜索妙记 / 某个关键词的妙记" → `minutes +search`
+> - 用户只是想看"我的妙记 / 某段时间内的妙记 / 妙记列表",不要先走 [lark-vc](../lark-vc/SKILL.md),而应直接使用本 skill
+> - 用户如果同时提到"会议 / 会 / 开会 / 某场会",即使也提到了"妙记",也应优先走 [lark-vc](../lark-vc/SKILL.md) 先定位会议,再通过 [vc +recording](../lark-vc/references/lark-vc-recording.md) 获取 `minute_token`
+> - 用户说"我的妙记 / 我拥有的妙记 / 我参与的妙记"时,可将相关过滤条件映射为 `me`;`me` 表示当前用户
+> - 结果有多页时,使用 `page_token` 持续翻页,直到确认没有更多结果
+> - `minutes +search` 单次最多返回 `200` 条;结果总数没有固定上限
+> - 用户说"这个妙记的标题 / 时长 / 封面 / 链接" → `minutes minutes get`
+> - 用户说"下载这个妙记的视频 / 音频 / 媒体文件" → `minutes +download`
+> - 用户说"这个妙记的逐字稿 / 总结 / 待办 / 章节" → 使用 [vc +notes --minute-tokens](../lark-vc/references/lark-vc-notes.md)
+
+## Shortcuts(推荐优先使用)
+
+Shortcut 是对常用操作的高级封装(`lark-cli minutes + [flags]`)。有 Shortcut 的操作优先使用。
+
+| Shortcut | 说明 |
+| -------------------------------------------------- | --------------------------------------------------------------- |
+| [`+search`](references/lark-minutes-search.md) | Search minutes by keyword, owners, participants, and time range |
+| [`+download`](references/lark-minutes-download.md) | Download audio/video media file of a minute |
+
+- 使用 `+search` 命令时,必须阅读 [references/lark-minutes-search.md](references/lark-minutes-search.md),了解搜索参数和返回值结构。
+- 使用 `+download` 命令时,必须阅读 [references/lark-minutes-download.md](references/lark-minutes-download.md),了解下载参数和返回值结构。
+
+
## API Resources
```bash
@@ -83,14 +103,14 @@ lark-cli minutes [flags] # 调用 API
### minutes
- - `get` — 获取妙记信息
+- `get` — 获取妙记信息
## 权限表
-| 方法 | 所需 scope |
-|------|-----------|
-| `minutes.get` | `minutes:minutes:readonly` |
-| `+download` | `minutes:minutes.media:export` |
-
+| 方法 | 所需 scope |
+| ------------- | ------------------------------ |
+| `+search` | `minutes:minutes.search:read` |
+| `minutes.get` | `minutes:minutes:readonly` |
+| `+download` | `minutes:minutes.media:export` |
diff --git a/skills/lark-minutes/references/lark-minutes-search.md b/skills/lark-minutes/references/lark-minutes-search.md
new file mode 100644
index 000000000..f3c2fb35e
--- /dev/null
+++ b/skills/lark-minutes/references/lark-minutes-search.md
@@ -0,0 +1,180 @@
+# minutes +search
+
+> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
+
+搜索妙记列表,支持关键词、所有者、参与者以及时间范围等多条件过滤。所有者与参与者都支持传入多个 open\_id,也支持传入 `me` 表示当前用户。只读操作,不修改任何妙记数据。
+
+本 skill 对应 shortcut:`lark-cli minutes +search`(调用 `POST /open-apis/minutes/v1/minutes/search`)。
+
+## 典型触发表达
+
+以下说法通常应优先使用 `minutes +search`:
+
+- 我的妙记
+- 我拥有的妙记
+- 我参与的妙记
+- 最近的妙记
+- 某个关键词的妙记
+- 某段时间内的妙记
+
+## 命令
+
+```bash
+# 关键词搜索
+lark-cli minutes +search --query "预算复盘"
+
+# 查询某一天内的妙记(单日查询时,建议将 start 和 end 都填写为同一天)
+lark-cli minutes +search --start 2026-03-10 --end 2026-03-10
+
+# 按时间范围搜索
+lark-cli minutes +search --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
+lark-cli minutes +search --start 2026-03-10 --end 2026-03-17
+
+# 关键词 + 时间范围
+lark-cli minutes +search --query "预算复盘" --start "2026-03-10T00:00+08:00" --end "2026-03-17T00:00+08:00"
+lark-cli minutes +search --query "预算复盘" --start "2026-03-10T00:00+08:00"
+lark-cli minutes +search --query "预算复盘" --end "2026-03-17T00:00+08:00"
+
+# 按参与者过滤(open_id,逗号分隔)
+lark-cli minutes +search --participant-ids "ou_x,ou_y"
+
+# 按所有者过滤(open_id,逗号分隔)
+lark-cli minutes +search --owner-ids "ou_owner,ou_owner_2"
+
+# 查询我参与的妙记
+lark-cli minutes +search --participant-ids "me"
+
+# 查询我拥有的妙记
+lark-cli minutes +search --owner-ids "me"
+
+# 多条件组合查询
+lark-cli minutes +search --owner-ids "ou_owner" --participant-ids "ou_x" --start "2026-03-10T00:00+08:00"
+
+# 分页查询
+lark-cli minutes +search --query "预算复盘" --page-size 20
+lark-cli minutes +search --query "预算复盘" --page-size 20 --page-token ''
+
+# 输出为结构化 JSON
+lark-cli minutes +search --query "预算复盘" --format json
+```
+
+## 参数
+
+| 参数 | 必填 | 说明 |
+| ------------------------- | -- | ------------------------------------ |
+| `--query ` | 否 | 搜索关键词 |
+| `--owner-ids ` | 否 | 所有者 open\_id 列表,逗号分隔;支持传 `me` 表示当前用户 |
+| `--participant-ids ` | 否 | 参与者 open\_id 列表,逗号分隔;支持传 `me` 表示当前用户 |
+| `--start