diff --git a/shortcuts/im/im_chat_create.go b/shortcuts/im/im_chat_create.go index 0f8a35431..edd688a73 100644 --- a/shortcuts/im/im_chat_create.go +++ b/shortcuts/im/im_chat_create.go @@ -40,6 +40,10 @@ var ImChatCreate = common.Shortcut{ {Name: "chat-mode", Default: "group", Desc: "group mode (\"topic\" creates a topic chat; differs from a normal group in topic-message mode)", Enum: []string{"group", "topic"}}, {Name: "set-bot-manager", Type: "bool", Desc: "set the bot that creates this chat as manager (bot identity only)"}, }, + Tips: []string{ + `Example: lark-cli im +chat-create --name "project chat"`, + `Example: lark-cli im +chat-create --name "project chat" --users ,`, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { body := buildCreateChatBody(runtime) params := map[string]interface{}{"user_id_type": "open_id"} diff --git a/shortcuts/im/im_chat_list.go b/shortcuts/im/im_chat_list.go index 2ae1ffd46..6a0332151 100644 --- a/shortcuts/im/im_chat_list.go +++ b/shortcuts/im/im_chat_list.go @@ -55,6 +55,10 @@ var ImChatList = common.Shortcut{ {Name: "page-token", Desc: "pagination token for next page"}, {Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"}, }, + Tips: []string{ + `Example: lark-cli im +chat-list`, + `Example: lark-cli im +chat-list --sort active_time`, + }, // DryRun previews the GET /open-apis/im/v1/chats request without executing. // When bot identity strips p2p from --types, emits the same stderr warning // Execute would emit, so DryRun output truthfully reflects what the API diff --git a/shortcuts/im/im_chat_members_list.go b/shortcuts/im/im_chat_members_list.go index d467af63c..35f78de6d 100644 --- a/shortcuts/im/im_chat_members_list.go +++ b/shortcuts/im/im_chat_members_list.go @@ -55,6 +55,8 @@ var ImChatMembersList = common.Shortcut{ {Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"}, }, Tips: []string{ + `Example: lark-cli im +chat-members-list --chat-id `, + `Example: lark-cli im +chat-members-list --chat-id --page-all`, "Default fetches a single page; pass --page-all to walk every page.", "With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.", "truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.", diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index 6356e5ecc..373e0b310 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -39,6 +39,10 @@ var ImChatMessageList = common.Shortcut{ {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, downloadResourcesFlag, }, + Tips: []string{ + `Example: lark-cli im +chat-messages-list --chat-id `, + `Example: lark-cli im +chat-messages-list --chat-id --start 2026-07-01 --end 2026-07-08 --order asc`, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { d := common.NewDryRunAPI() chatId, err := resolveChatIDForMessagesList(runtime, true) diff --git a/shortcuts/im/im_chat_search.go b/shortcuts/im/im_chat_search.go index ef46946ec..d43fca508 100644 --- a/shortcuts/im/im_chat_search.go +++ b/shortcuts/im/im_chat_search.go @@ -41,6 +41,9 @@ var ImChatSearch = common.Shortcut{ {Name: "page-token", Desc: "pagination token for next page"}, {Name: "exclude-muted", Type: "bool", Desc: "(user identity only) drop chats the current user has muted (do-not-disturb); bot identity returns all chats unfiltered"}, }, + Tips: []string{ + `Example: lark-cli im +chat-search --query "project"`, + }, // DryRun previews the POST /open-apis/im/v2/chats/search request without executing. DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { body := buildSearchChatBody(runtime) diff --git a/shortcuts/im/im_chat_update.go b/shortcuts/im/im_chat_update.go index 0e7411fb9..59b8d7f8d 100644 --- a/shortcuts/im/im_chat_update.go +++ b/shortcuts/im/im_chat_update.go @@ -28,6 +28,9 @@ var ImChatUpdate = common.Shortcut{ {Name: "name", Desc: "group name (max 60 chars)"}, {Name: "description", Desc: "group description (max 100 chars)"}, }, + Tips: []string{ + `Example: lark-cli im +chat-update --chat-id --name "new name"`, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { chatID := runtime.Str("chat-id") body := buildUpdateChatBody(runtime) diff --git a/shortcuts/im/im_messages_mget.go b/shortcuts/im/im_messages_mget.go index bf1a2e0a0..77c183ae2 100644 --- a/shortcuts/im/im_messages_mget.go +++ b/shortcuts/im/im_messages_mget.go @@ -32,6 +32,9 @@ var ImMessagesMGet = common.Shortcut{ {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, downloadResourcesFlag, }, + Tips: []string{ + `Example: lark-cli im +messages-mget --message-ids ,`, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { ids := common.SplitCSV(runtime.Str("message-ids")) d := common.NewDryRunAPI().GET(buildMGetURL(ids)) diff --git a/shortcuts/im/im_messages_reply.go b/shortcuts/im/im_messages_reply.go index 1aaf4ba83..17866f20b 100644 --- a/shortcuts/im/im_messages_reply.go +++ b/shortcuts/im/im_messages_reply.go @@ -37,6 +37,10 @@ var ImMessagesReply = common.Shortcut{ {Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"}, {Name: "idempotency-key", Desc: "idempotency key, max 50 characters (prevents duplicate sends)"}, }, + Tips: []string{ + `Example: lark-cli im +messages-reply --message-id --text "reply"`, + `Example: lark-cli im +messages-reply --message-id --text "reply" --reply-in-thread`, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") msgType := runtime.Str("msg-type") diff --git a/shortcuts/im/im_messages_resources_download.go b/shortcuts/im/im_messages_resources_download.go index 1327e00f1..c3c6fceb4 100644 --- a/shortcuts/im/im_messages_resources_download.go +++ b/shortcuts/im/im_messages_resources_download.go @@ -34,6 +34,10 @@ var ImMessagesResourcesDownload = common.Shortcut{ {Name: "type", Desc: "resource type (image or file)", Required: true, Enum: []string{"image", "file"}}, {Name: "output", Desc: "local save path (relative only, no .. traversal); when omitted, uses the server's Content-Disposition filename if available, otherwise file_key; extension is inferred from Content-Disposition or Content-Type if not provided"}, }, + Tips: []string{ + `Example: lark-cli im +messages-resources-download --message-id --file-key --type file`, + `Example: lark-cli im +messages-resources-download --message-id --file-key --type image --output ./downloads/pic.png`, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { fileKey := runtime.Str("file-key") outputPath := runtime.Str("output") diff --git a/shortcuts/im/im_messages_search.go b/shortcuts/im/im_messages_search.go index f714006a7..fcfec9425 100644 --- a/shortcuts/im/im_messages_search.go +++ b/shortcuts/im/im_messages_search.go @@ -51,6 +51,10 @@ var ImMessagesSearch = common.Shortcut{ {Name: "page-limit", Type: "int", Default: "20", Desc: "max search pages when auto-pagination is enabled (default 20, max 40)"}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, }, + Tips: []string{ + `Example: lark-cli im +messages-search --query "keyword"`, + `Example: lark-cli im +messages-search --query "keyword" --chat-id --start 2026-07-01 --end 2026-07-08`, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { req, err := buildMessagesSearchRequest(runtime) if err != nil { diff --git a/shortcuts/im/im_messages_send.go b/shortcuts/im/im_messages_send.go index 8633aab16..59d908e5d 100644 --- a/shortcuts/im/im_messages_send.go +++ b/shortcuts/im/im_messages_send.go @@ -39,6 +39,11 @@ var ImMessagesSend = common.Shortcut{ {Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"}, {Name: "audio", Desc: audioMessageInputDesc}, }, + Tips: []string{ + `Example: lark-cli im +messages-send --chat-id --text "hello"`, + `Example: lark-cli im +messages-send --user-id --text "hello"`, + `Example: lark-cli im +messages-send --chat-id --markdown "## update"`, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { chatFlag := runtime.Str("chat-id") userFlag := runtime.Str("user-id") diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 1cdc83363..95d374e73 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -38,6 +38,9 @@ var ImThreadsMessagesList = common.Shortcut{ {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, downloadResourcesFlag, }, + Tips: []string{ + `Example: lark-cli im +threads-messages-list --thread `, + }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { threadFlag := runtime.Str("thread") dir := resolveThreadsOrder(runtime) diff --git a/shortcuts/im/tips_examples_test.go b/shortcuts/im/tips_examples_test.go new file mode 100644 index 000000000..dca8399c0 --- /dev/null +++ b/shortcuts/im/tips_examples_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "regexp" + "strings" + "testing" + + "github.com/larksuite/cli/shortcuts/common" +) + +// The 12 high-frequency IM shortcuts covered by the governance closeout. +// Every entry must carry at least one copyable "Example:" tip locked by the +// tests below; other IM shortcuts (feed/flag series) are intentionally exempt. +var tipsExampleTargets = []string{ + "+messages-send", "+messages-search", "+chat-messages-list", "+messages-reply", + "+chat-search", "+chat-list", "+messages-mget", "+threads-messages-list", + "+messages-resources-download", "+chat-create", "+chat-update", "+chat-members-list", +} + +var exampleFlagTokenRe = regexp.MustCompile(`--[a-z][a-z0-9-]*`) + +// Flags injected by the shortcut runner framework rather than declared in +// Shortcut.Flags. --format comes with HasFormat, --json with HasJSON. +var frameworkInjectedFlags = map[string]bool{ + "--json": true, "--dry-run": true, "--as": true, "--yes": true, "--format": true, +} + +func shortcutByCommand(t *testing.T, command string) common.Shortcut { + t.Helper() + for _, sc := range Shortcuts() { + if sc.Command == command { + return sc + } + } + t.Fatalf("shortcut %s not registered in Shortcuts()", command) + return common.Shortcut{} +} + +// exampleCommands returns the command lines of "Example: ..." tips, with the +// "Example: " prefix stripped. +func exampleCommands(sc common.Shortcut) []string { + prefix := "Example: lark-cli im " + sc.Command + var out []string + for _, tip := range sc.Tips { + if strings.HasPrefix(tip, prefix+" ") || tip == prefix { + out = append(out, strings.TrimPrefix(tip, "Example: ")) + } + } + return out +} + +func TestIMTipsExamplesPresent(t *testing.T) { + for _, cmd := range tipsExampleTargets { + sc := shortcutByCommand(t, cmd) + examples := exampleCommands(sc) + if len(examples) < 1 { + t.Errorf("%s: want >=1 tip starting with %q, got none (tips=%q)", + cmd, "Example: lark-cli im "+cmd, sc.Tips) + } + if len(examples) > 3 { + t.Errorf("%s: want <=3 examples to keep help focused, got %d", cmd, len(examples)) + } + } +} + +func TestIMTipsExampleFlagsExist(t *testing.T) { + for _, cmd := range tipsExampleTargets { + sc := shortcutByCommand(t, cmd) + declared := map[string]bool{} + for _, f := range sc.Flags { + declared["--"+f.Name] = true + } + for _, example := range exampleCommands(sc) { + for _, tok := range exampleFlagTokenRe.FindAllString(example, -1) { + if !declared[tok] && !frameworkInjectedFlags[tok] { + t.Errorf("%s: example uses %s which is neither a declared flag nor framework-injected\nexample: %s", + cmd, tok, example) + } + } + } + } +} + +func TestIMTipsFirstExampleCoversRequired(t *testing.T) { + for _, cmd := range tipsExampleTargets { + sc := shortcutByCommand(t, cmd) + examples := exampleCommands(sc) + if len(examples) == 0 { + continue // reported by TestIMTipsExamplesPresent + } + for _, f := range sc.Flags { + if !f.Required { + continue + } + if !strings.Contains(examples[0], "--"+f.Name) { + t.Errorf("%s: first example must cover required flag --%s\nexample: %s", + cmd, f.Name, examples[0]) + } + } + } +} diff --git a/tests/cli_e2e/im/tips_examples_dryrun_test.go b/tests/cli_e2e/im/tips_examples_dryrun_test.go new file mode 100644 index 000000000..592f03ede --- /dev/null +++ b/tests/cli_e2e/im/tips_examples_dryrun_test.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "strings" + "testing" + "time" + + imshortcuts "github.com/larksuite/cli/shortcuts/im" + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" +) + +// Placeholder substitutions turning copyable help examples into syntactically +// valid dry-run invocations. IDs are obvious fakes; --dry-run never hits the API. +var tipsPlaceholderValues = map[string]string{ + "": "oc_e2etest000000000000000000", + "": "ou_e2etest000000000000000000", + "": "om_e2etest000000000000000000", + "": "omt_e2etest00000000000000000", + "": "file_v3_e2etest0000000000000", + "": "img_v3_e2etest00000000000000", + "": "ou_e2etest000000000000000001", + "": "ou_e2etest000000000000000002", + "": "om_e2etest000000000000000001", + "": "om_e2etest000000000000000002", +} + +// firstExampleArgs extracts the first "Example:" tip of the shortcut, replaces +// placeholders, and returns the argv after "lark-cli". +func firstExampleArgs(t *testing.T, command string) []string { + t.Helper() + for _, sc := range imshortcuts.Shortcuts() { + if sc.Command != command { + continue + } + prefix := "Example: lark-cli " + for _, tip := range sc.Tips { + if !strings.HasPrefix(tip, prefix) { + continue + } + line := strings.TrimPrefix(tip, prefix) + for ph, v := range tipsPlaceholderValues { + line = strings.ReplaceAll(line, ph, v) + } + return splitExampleArgs(t, line) + } + t.Fatalf("%s has no Example tip", command) + } + t.Fatalf("shortcut %s not found", command) + return nil +} + +// splitExampleArgs splits a shell-like example line on spaces, honoring +// double-quoted segments (the only quoting style used in Tips examples). +func splitExampleArgs(t *testing.T, line string) []string { + t.Helper() + var args []string + var cur strings.Builder + inQuote := false + for _, r := range line { + switch { + case r == '"': + inQuote = !inQuote + case r == ' ' && !inQuote: + if cur.Len() > 0 { + args = append(args, cur.String()) + cur.Reset() + } + default: + cur.WriteRune(r) + } + } + if inQuote { + t.Fatalf("unbalanced quotes in example: %s", line) + } + if cur.Len() > 0 { + args = append(args, cur.String()) + } + return args +} + +func runFirstExampleDryRun(t *testing.T, command string, wantAPIPath string) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "im_tips_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "im_tips_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + args := append(firstExampleArgs(t, command), "--dry-run") + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: args, + DefaultAs: "bot", + WorkDir: t.TempDir(), + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + require.Contains(t, result.Stdout, wantAPIPath, + "dry-run output should reference the upstream API path") +} + +func TestIMTipsFirstExampleDryRunMessagesSend(t *testing.T) { + runFirstExampleDryRun(t, "+messages-send", "/open-apis/im/v1/messages") +} + +func TestIMTipsFirstExampleDryRunChatMessagesList(t *testing.T) { + runFirstExampleDryRun(t, "+chat-messages-list", "/open-apis/im/v1/messages") +} + +func TestIMTipsFirstExampleDryRunResourcesDownload(t *testing.T) { + runFirstExampleDryRun(t, "+messages-resources-download", "/open-apis/im/v1/messages/") +}