From dc5705f912c1ef5255ceaa1ccd01ddeb1faa0c27 Mon Sep 17 00:00:00 2001 From: "xiaoxiangyu.123" Date: Tue, 1 Sep 2026 20:39:21 +0800 Subject: [PATCH] feat(im): add concise message output --- cmd/root_integration_test.go | 186 +++++++ shortcuts/im/im_chat_messages_list.go | 20 +- shortcuts/im/im_list_page_all_test.go | 153 ++++++ shortcuts/im/im_threads_messages_list.go | 20 +- shortcuts/im/message_concise.go | 494 ++++++++++++++++++ shortcuts/im/message_concise_test.go | 261 +++++++++ skills/lark-im/SKILL.md | 4 + .../references/lark-im-chat-messages-list.md | 4 + .../lark-im-threads-messages-list.md | 4 + 9 files changed, 1140 insertions(+), 6 deletions(-) create mode 100644 shortcuts/im/message_concise.go create mode 100644 shortcuts/im/message_concise_test.go diff --git a/cmd/root_integration_test.go b/cmd/root_integration_test.go index a288059e8e..03e550a6f9 100644 --- a/cmd/root_integration_test.go +++ b/cmd/root_integration_test.go @@ -7,6 +7,8 @@ import ( "bytes" "context" "encoding/json" + "fmt" + "net/http" "os" "strings" "testing" @@ -14,6 +16,7 @@ import ( "github.com/larksuite/cli/cmd/api" "github.com/larksuite/cli/cmd/auth" "github.com/larksuite/cli/cmd/service" + extcs "github.com/larksuite/cli/extension/contentsafety" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/cmdutil" @@ -78,6 +81,11 @@ type typedErrorEnvelope struct { Message string `json:"message"` Hint string `json:"hint"` Param string `json:"param,omitempty"` + Params []struct { + Name string `json:"name"` + Reason string `json:"reason"` + } `json:"params,omitempty"` + Rules []string `json:"rules,omitempty"` } `json:"error"` } @@ -471,6 +479,184 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop // --- shortcut command --- +type conciseViewSafetyProvider struct { + wantTitle string +} + +func (p *conciseViewSafetyProvider) Name() string { return "concise-fixture" } + +func (p *conciseViewSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) { + encoded, err := json.Marshal(req.Data) + if err != nil { + return nil, err + } + // Match fields owned by conciseMessageView rather than the legacy output + // map, so this fixture fails if the command scans data other than the view + // that is passed to the Markdown renderer. + if !bytes.Contains(encoded, []byte(`"Title":`+fmt.Sprintf("%q", p.wantTitle))) || + !bytes.Contains(encoded, []byte(`"ChatSections"`)) { + return nil, nil + } + return &extcs.Alert{Provider: p.Name(), MatchedRules: []string{"fixture-rule"}}, nil +} + +type conciseIntegrationCommand struct { + name string + title string + args []string +} + +func conciseIntegrationCommands() []conciseIntegrationCommand { + return []conciseIntegrationCommand{ + {name: "chat-messages-list", title: "Chat messages", args: []string{"im", "+chat-messages-list", "--chat-id", "oc_test"}}, + {name: "threads-messages-list", title: "Thread messages", args: []string{"im", "+threads-messages-list", "--thread", "omt_test"}}, + } +} + +func registerConciseMessageListStub(reg *httpmock.Registry) { + reg.Register(&httpmock.Stub{ + Method: http.MethodGet, + URL: "/open-apis/im/v1/messages", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{ + "message_id": "om_fixture", + "msg_type": "text", + "body": map[string]interface{}{"content": `{"text":"fixture message"}`}, + "create_time": "0", + }}, + "has_more": false, + "page_token": "", + }, + }, + }) +} + +func TestIntegration_IMConciseContentSafety(t *testing.T) { + for _, command := range conciseIntegrationCommands() { + for _, mode := range []string{"warn", "block"} { + t.Run(command.name+"/"+mode, func(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode) + previousProvider := extcs.GetProvider() + extcs.Register(&conciseViewSafetyProvider{wantTitle: command.title}) + t.Cleanup(func() { extcs.Register(previousProvider) }) + + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "concise-safety", AppSecret: "secret", Brand: core.BrandFeishu, + }) + registerConciseMessageListStub(reg) + rootCmd := buildIntegrationRootCmd(t, f) + args := append(append([]string{}, command.args...), "--as", "bot", "--concise", "--no-reactions") + code := executeRootIntegration(t, f, rootCmd, args) + + if mode == "warn" { + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + if !strings.Contains(stdout.String(), "# "+command.title) || !strings.Contains(stdout.String(), "fixture message") { + t.Fatalf("concise stdout = %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "warning: content safety alert from concise-fixture (rules: fixture-rule)") { + t.Fatalf("warn stderr = %q", stderr.String()) + } + return + } + + if code != output.ExitContentSafety { + t.Fatalf("exit code = %d, want %d", code, output.ExitContentSafety) + } + if stdout.Len() != 0 { + t.Fatalf("block stdout = %q, want empty", stdout.String()) + } + env := parseTypedEnvelope(t, stderr) + if env.Error.Type != "policy" || env.Error.Subtype != "content_safety" || len(env.Error.Rules) != 1 || env.Error.Rules[0] != "fixture-rule" { + t.Fatalf("block envelope = %#v", env) + } + }) + } + } +} + +func TestIntegration_IMConciseOutputFlagConflicts(t *testing.T) { + conflicts := []struct { + name string + args []string + flag string + }{ + {name: "format", args: []string{"--format", "pretty"}, flag: "--format"}, + {name: "json", args: []string{"--json"}, flag: "--json"}, + {name: "jq", args: []string{"--jq", ".data"}, flag: "--jq"}, + } + for _, command := range conciseIntegrationCommands() { + for _, conflict := range conflicts { + t.Run(command.name+"/"+conflict.name, func(t *testing.T) { + f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "concise-conflict", AppSecret: "secret", Brand: core.BrandFeishu, + }) + requestCount := 0 + reg.Register(&httpmock.Stub{ + URL: "/open-apis/", Optional: true, Reusable: true, + OnMatch: func(*http.Request) { requestCount++ }, + }) + rootCmd := buildIntegrationRootCmd(t, f) + args := append(append([]string{}, command.args...), "--as", "bot", "--concise") + args = append(args, conflict.args...) + code := executeRootIntegration(t, f, rootCmd, args) + + if code != output.ExitValidation { + t.Fatalf("exit code = %d, want %d", code, output.ExitValidation) + } + if stdout.Len() != 0 || requestCount != 0 { + t.Fatalf("stdout = %q, requests = %d; want empty stdout and no request", stdout.String(), requestCount) + } + env := parseTypedEnvelope(t, stderr) + if env.Error.Type != "validation" || env.Error.Param != "--concise" || !strings.Contains(env.Error.Message, conflict.flag) { + t.Fatalf("conflict envelope = %#v", env) + } + if len(env.Error.Params) != 2 || env.Error.Params[0].Name != "--concise" || env.Error.Params[1].Name != conflict.flag { + t.Fatalf("conflict params = %#v", env.Error.Params) + } + }) + } + } +} + +func TestIntegration_IMConciseDisabledOutputFlagsAreAllowed(t *testing.T) { + disabledFlags := []struct { + name string + arg string + }{ + {name: "json-false", arg: "--json=false"}, + {name: "jq-empty", arg: "--jq="}, + } + for _, command := range conciseIntegrationCommands() { + for _, disabled := range disabledFlags { + t.Run(command.name+"/"+disabled.name, func(t *testing.T) { + f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "concise-disabled", AppSecret: "secret", Brand: core.BrandFeishu, + }) + rootCmd := buildIntegrationRootCmd(t, f) + args := append(append([]string{}, command.args...), "--as", "bot", "--dry-run", "--concise", "--no-reactions", disabled.arg) + code := executeRootIntegration(t, f, rootCmd, args) + if code != 0 { + t.Fatalf("exit code = %d, want 0; stderr: %s", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q, want empty", stderr.String()) + } + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String()) + } + if envelope["ok"] != true || envelope["dry_run"] != true { + t.Fatalf("dry-run envelope = %#v", envelope) + } + }) + } + } +} + func TestIntegration_Shortcut_BusinessError_OutputsEnvelope(t *testing.T) { f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "e2e-sc-err", AppSecret: "secret", Brand: core.BrandFeishu, diff --git a/shortcuts/im/im_chat_messages_list.go b/shortcuts/im/im_chat_messages_list.go index b71f051193..0fccb4bd6c 100644 --- a/shortcuts/im/im_chat_messages_list.go +++ b/shortcuts/im/im_chat_messages_list.go @@ -42,6 +42,7 @@ var ImChatMessageList = common.Shortcut{ {Name: "page-size", Aliases: []string{"limit"}, Default: fmt.Sprintf("%d", chatMessagesListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", chatMessagesListMaxPageSize)}, {Name: "page-token", Desc: "starting pagination cursor"}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, + {Name: "concise", Type: "bool", Desc: "render compact Markdown for message context"}, downloadResourcesFlag, }, common.PageAllFlags()...), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -77,6 +78,9 @@ var ImChatMessageList = common.Shortcut{ return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateConciseOutputFlags(runtime); err != nil { + return err + } // Under bot identity, --user-id is not supported; require --chat-id only. if runtime.IsBot() { if runtime.Str("user-id") != "" { @@ -184,9 +188,19 @@ var ImChatMessageList = common.Shortcut{ "has_more": hasMore, "page_token": nextPageToken, } - runtime.OutFormat(outData, &output.Meta{ - Pagination: pagination, - }, func(w io.Writer) { + if runtime.Bool("concise") { + return outputMessagesConcise(runtime, conciseMessageView{ + Type: conciseMessageViewChat, + Title: "Chat messages", + ChatSections: []conciseChatSection{{ + ChatID: chatId, + Messages: messages, + }}, + HasMore: hasMore, + NextToken: nextPageToken, + }) + } + runtime.OutFormat(outData, &output.Meta{Pagination: pagination}, func(w io.Writer) { if len(messages) == 0 { fmt.Fprintln(w, "No messages in this time range.") return diff --git a/shortcuts/im/im_list_page_all_test.go b/shortcuts/im/im_list_page_all_test.go index a986b6e166..49c8b5b021 100644 --- a/shortcuts/im/im_list_page_all_test.go +++ b/shortcuts/im/im_list_page_all_test.go @@ -365,6 +365,159 @@ func TestIMListSinglePageUsesUnifiedPaginationMeta(t *testing.T) { } } +func TestMessageListConciseOutputUsesCommandRenderer(t *testing.T) { + for _, tc := range listPageAllCases()[:2] { + t.Run(tc.name, func(t *testing.T) { + runtime, calls := newListPageAllRuntime(t, tc, nil, func(_ *http.Request, _ int) map[string]interface{} { + return map[string]interface{}{ + "items": []interface{}{tc.makeRawItem("om_concise")}, + "has_more": true, + "page_token": "next", + "total": 1, + } + }) + if err := runtime.Cmd.Flags().Set("concise", "true"); err != nil { + t.Fatalf("set --concise: %v", err) + } + + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if *calls != 1 { + t.Fatalf("API calls = %d, want 1", *calls) + } + stdout := runtime.IO().Out.(*bytes.Buffer).String() + for _, want := range []string{ + "## Messages", + "message_id: `om_concise`", + "> om_concise", + "- has_more: true", + "- next_token: `next`", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("concise stdout missing %q:\n%s", want, stdout) + } + } + if strings.Contains(stdout, `"ok"`) || strings.Contains(stdout, "Pagination:") { + t.Fatalf("concise stdout used another output contract:\n%s", stdout) + } + if tc.name == "chat-messages-list" { + for _, want := range []string{"- thread_replies: 0", "- threads: 0", "- chats: 1"} { + if !strings.Contains(stdout, want) { + t.Fatalf("chat concise stdout missing %q:\n%s", want, stdout) + } + } + } else { + for _, forbidden := range []string{"- thread_replies:", "- threads:", "- chats:"} { + if strings.Contains(stdout, forbidden) { + t.Fatalf("thread concise stdout contains %q:\n%s", forbidden, stdout) + } + } + } + }) + } +} + +func TestMessageListConcisePageAllUsesFinalPaginationState(t *testing.T) { + for _, tc := range listPageAllCases()[:2] { + t.Run(tc.name, func(t *testing.T) { + runtime, calls := newListPageAllRuntime(t, tc, map[string]string{ + "page-all": "true", + "page-limit": "2", + }, func(_ *http.Request, call int) map[string]interface{} { + return map[string]interface{}{ + "items": []interface{}{tc.makeRawItem(fmt.Sprintf("item-%d", call))}, + "has_more": true, + "page_token": fmt.Sprintf("token-%d", call), + "total": 10, + } + }) + if err := runtime.Cmd.Flags().Set("concise", "true"); err != nil { + t.Fatalf("set --concise: %v", err) + } + + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if *calls != 2 { + t.Fatalf("API calls = %d, want 2", *calls) + } + stdout := runtime.IO().Out.(*bytes.Buffer).String() + for _, want := range []string{ + "message_id: `item-1`", + "message_id: `item-2`", + "- messages: 2", + "- has_more: true", + "- next_token: `token-2`", + } { + if !strings.Contains(stdout, want) { + t.Fatalf("concise stdout missing %q:\n%s", want, stdout) + } + } + if strings.Contains(stdout, "Pagination:") || strings.Contains(stdout, `"meta"`) { + t.Fatalf("concise stdout contains a second pagination contract:\n%s", stdout) + } + }) + } +} + +func TestMessageListFormatConciseKeepsUnknownFormatFallback(t *testing.T) { + for _, tc := range listPageAllCases()[:2] { + t.Run(tc.name, func(t *testing.T) { + runtime, calls := newListPageAllRuntime(t, tc, nil, func(_ *http.Request, _ int) map[string]interface{} { + return map[string]interface{}{ + "items": []interface{}{tc.makeRawItem("om_json")}, "has_more": false, "page_token": "", + } + }) + runtime.Format = "concise" + + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if *calls != 1 { + t.Fatalf("API calls = %d, want 1", *calls) + } + envelope := listPageAllOutputEnvelope(t, runtime) + if envelope["ok"] != true { + t.Fatalf("fallback envelope = %#v", envelope) + } + stderr := runtime.IO().ErrOut.(*bytes.Buffer).String() + if !strings.Contains(stderr, `warning: unknown format "concise", falling back to json`) { + t.Fatalf("fallback stderr = %q", stderr) + } + }) + } +} + +func TestMessageListExistingFormatsRemainAvailable(t *testing.T) { + for _, tc := range listPageAllCases()[:2] { + for _, format := range []string{"json", "pretty", "table", "ndjson", "csv"} { + t.Run(tc.name+"/"+format, func(t *testing.T) { + runtime, calls := newListPageAllRuntime(t, tc, nil, func(_ *http.Request, _ int) map[string]interface{} { + return map[string]interface{}{ + "items": []interface{}{tc.makeRawItem("om_existing")}, "has_more": false, "page_token": "", + } + }) + runtime.Format = format + + if err := tc.shortcut.Execute(context.Background(), runtime); err != nil { + t.Fatalf("Execute() error = %v", err) + } + if *calls != 1 { + t.Fatalf("API calls = %d, want 1", *calls) + } + stdout := runtime.IO().Out.(*bytes.Buffer).String() + if !strings.Contains(stdout, "om_existing") { + t.Fatalf("%s stdout omitted message data: %s", format, stdout) + } + if strings.Contains(stdout, "## Messages") { + t.Fatalf("%s unexpectedly entered concise renderer: %s", format, stdout) + } + }) + } + } +} + func TestChatListRecordFormatsKeepStdoutPureAndReportPagination(t *testing.T) { var tc listPageAllCase for _, candidate := range listPageAllCases() { diff --git a/shortcuts/im/im_threads_messages_list.go b/shortcuts/im/im_threads_messages_list.go index 1c99e310a8..a482eb3eca 100644 --- a/shortcuts/im/im_threads_messages_list.go +++ b/shortcuts/im/im_threads_messages_list.go @@ -39,6 +39,7 @@ var ImThreadsMessagesList = common.Shortcut{ {Name: "page-size", Default: fmt.Sprintf("%d", threadsMessagesListDefaultPageSize), Desc: fmt.Sprintf("page size (1-%d)", threadsMessagesListMaxPageSize)}, {Name: "page-token", Desc: "starting pagination cursor"}, {Name: "no-reactions", Type: "bool", Desc: "skip auto-fetching reactions for each message (default: enrichment enabled)"}, + {Name: "concise", Type: "bool", Desc: "render compact Markdown for message context"}, downloadResourcesFlag, }, common.PageAllFlags()...), DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -77,6 +78,9 @@ var ImThreadsMessagesList = common.Shortcut{ return d }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateConciseOutputFlags(runtime); err != nil { + return err + } threadId := runtime.Str("thread") const threadParam = "--thread" if threadId == "" { @@ -157,9 +161,19 @@ var ImThreadsMessagesList = common.Shortcut{ "has_more": hasMore, "page_token": nextPageToken, } - runtime.OutFormat(outData, &output.Meta{ - Pagination: pagination, - }, func(w io.Writer) { + if runtime.Bool("concise") { + return outputMessagesConcise(runtime, conciseMessageView{ + Type: conciseMessageViewThread, + Title: "Thread messages", + ChatSections: []conciseChatSection{{ + ThreadID: threadId, + Messages: messages, + }}, + HasMore: hasMore, + NextToken: nextPageToken, + }) + } + runtime.OutFormat(outData, &output.Meta{Pagination: pagination}, func(w io.Writer) { if len(messages) == 0 { fmt.Fprintln(w, "No messages in this thread.") return diff --git a/shortcuts/im/message_concise.go b/shortcuts/im/message_concise.go new file mode 100644 index 0000000000..fcfba69bec --- /dev/null +++ b/shortcuts/im/message_concise.go @@ -0,0 +1,494 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var conciseInlineReplacer = strings.NewReplacer( + `\`, `\\`, + "`", `\`+"`", + `*`, `\*`, + `_`, `\_`, + `[`, `\[`, + `]`, `\]`, + `<`, `\<`, + `>`, `\>`, + `#`, `\#`, + `~`, `\~`, +) + +type conciseChatSection struct { + ChatID string + ChatName string + ChatType string + ChatPartnerID string + ThreadID string + Messages []map[string]interface{} +} + +type conciseMessageViewType string + +const ( + conciseMessageViewChat conciseMessageViewType = "chat" + conciseMessageViewThread conciseMessageViewType = "thread" +) + +type conciseMessageView struct { + Type conciseMessageViewType + Title string + ChatSections []conciseChatSection + HasMore bool + NextToken string +} + +type conciseParticipant struct { + ID string + Name string + Type string + OpenBotID string +} + +type conciseMessageStats struct { + Messages int + Replies int + Threads map[string]struct{} +} + +func validateConciseOutputFlags(runtime *common.RuntimeContext) error { + if !runtime.Bool("concise") { + return nil + } + + conflict := "" + switch { + case runtime.Bool("json"): + conflict = "--json" + case runtime.Str("jq") != "": + conflict = "--jq" + case runtime.Changed("format"): + conflict = "--format" + } + if conflict == "" { + return nil + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--concise cannot be used with %s", conflict). + WithParam("--concise"). + WithParams( + errs.InvalidParam{Name: "--concise", Reason: "mutually exclusive with " + conflict}, + errs.InvalidParam{Name: conflict, Reason: "mutually exclusive with --concise"}, + ) +} + +// outputMessagesConcise keeps the command-specific Markdown path inside IM. +// Generic formats continue to use RuntimeContext.OutFormat unchanged. +func outputMessagesConcise(runtime *common.RuntimeContext, view conciseMessageView) error { + streams := runtime.IO() + scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), view, streams.ErrOut) + if scanResult.Blocked { + return scanResult.BlockErr + } + if scanResult.Alert != nil { + if err := output.WriteAlertWarning(streams.ErrOut, scanResult.Alert); err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "failed to write concise output warning").WithCause(err) + } + } + + var rendered bytes.Buffer + if err := renderMessagesConcise(&rendered, view); err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "failed to render concise output").WithCause(err) + } + if _, err := io.Copy(streams.Out, &rendered); err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "failed to write concise output").WithCause(err) + } + return nil +} + +func renderMessagesConcise(w io.Writer, view conciseMessageView) error { + var b strings.Builder + title := view.Title + if title == "" { + title = "Messages" + } + fmt.Fprintf(&b, "# %s\n", conciseInline(title)) + + if len(view.ChatSections) == 1 { + renderConciseSectionMetadata(&b, view.ChatSections[0], true) + } + + participants := collectConciseParticipants(view.ChatSections) + if len(participants) > 0 { + b.WriteString("\n## Participants\n\n") + for _, participant := range participants { + fmt.Fprintf(&b, "- %s (%s", conciseInline(participant.Name), conciseCode(participant.ID)) + if participant.Type != "" { + fmt.Fprintf(&b, ", %s", conciseInline(participant.Type)) + } + if participant.OpenBotID != "" { + fmt.Fprintf(&b, ", bot_open_id: %s", conciseCode(participant.OpenBotID)) + } + b.WriteString(")\n") + } + } + + b.WriteString("\n## Messages\n") + stats := conciseMessageStats{Threads: map[string]struct{}{}} + for _, section := range view.ChatSections { + stats.Messages += len(section.Messages) + if section.ThreadID != "" { + stats.Threads[section.ThreadID] = struct{}{} + } + } + if stats.Messages == 0 { + b.WriteString("\nNo messages found.\n") + } else { + multipleSections := len(view.ChatSections) > 1 + for index, section := range view.ChatSections { + if multipleSections { + fmt.Fprintf(&b, "\n### %s\n", conciseSectionTitle(section, index)) + renderConciseSectionMetadata(&b, section, false) + } + if len(section.Messages) == 0 { + if multipleSections { + b.WriteString("\nNo messages found in this section.\n") + } else { + b.WriteString("\nNo messages found.\n") + } + continue + } + for _, message := range section.Messages { + b.WriteByte('\n') + renderConciseMessage(&b, message, "", false, &stats) + } + } + } + + b.WriteString("\n## Summary\n") + fmt.Fprintf(&b, "\n- messages: %d\n", stats.Messages) + if view.Type == conciseMessageViewChat { + fmt.Fprintf(&b, "- thread_replies: %d\n", stats.Replies) + fmt.Fprintf(&b, "- threads: %d\n", len(stats.Threads)) + fmt.Fprintf(&b, "- chats: %d\n", conciseChatCount(view.ChatSections)) + } + fmt.Fprintf(&b, "- has_more: %t\n", view.HasMore) + if view.HasMore && view.NextToken != "" { + fmt.Fprintf(&b, "- next_token: %s\n", conciseCode(view.NextToken)) + } + + _, err := io.WriteString(w, b.String()) + return err +} + +func renderConciseSectionMetadata(b *strings.Builder, section conciseChatSection, includeName bool) { + if section.ChatID != "" { + fmt.Fprintf(b, "\n- chat_id: %s\n", conciseCode(section.ChatID)) + } + if includeName && section.ChatName != "" { + fmt.Fprintf(b, "- chat_name: %s\n", conciseInline(section.ChatName)) + } + if section.ChatType != "" { + fmt.Fprintf(b, "- chat_type: %s\n", conciseCode(section.ChatType)) + } + if section.ChatPartnerID != "" { + fmt.Fprintf(b, "- chat_partner: %s\n", conciseCode(section.ChatPartnerID)) + } + if section.ThreadID != "" { + fmt.Fprintf(b, "- thread_id: %s\n", conciseCode(section.ThreadID)) + } +} + +func conciseSectionTitle(section conciseChatSection, index int) string { + if section.ChatName != "" { + return "Chat: " + conciseInline(section.ChatName) + } + if section.ChatType == "p2p" { + return "Chat: P2P" + } + if section.ChatID != "" { + return "Chat: " + conciseCode(section.ChatID) + } + if section.ThreadID != "" { + return "Thread: " + conciseCode(section.ThreadID) + } + return fmt.Sprintf("Section %d", index+1) +} + +func conciseChatCount(sections []conciseChatSection) int { + chatIDs := make(map[string]struct{}) + for _, section := range sections { + if section.ChatID != "" { + chatIDs[section.ChatID] = struct{}{} + } + } + return len(chatIDs) +} + +func renderConciseMessage( + b *strings.Builder, + message map[string]interface{}, + indent string, + reply bool, + stats *conciseMessageStats, +) { + parts := make([]string, 0, 8) + if reply { + parts = append(parts, "**Reply**") + } + if created := conciseString(message["create_time"]); created != "" { + parts = append(parts, conciseCode(created)) + } + parts = append(parts, conciseSender(message)) + if msgType := conciseString(message["msg_type"]); msgType != "" { + parts = append(parts, conciseCode(msgType)) + } + if messageID := conciseString(message["message_id"]); messageID != "" { + parts = append(parts, "message_id: "+conciseCode(messageID)) + } + if replyTo := conciseString(message["reply_to"]); replyTo != "" { + parts = append(parts, "reply_to: "+conciseCode(replyTo)) + } + if threadID := conciseString(message["thread_id"]); threadID != "" { + parts = append(parts, "thread_id: "+conciseCode(threadID)) + stats.Threads[threadID] = struct{}{} + } + if conciseBool(message["updated"]) { + parts = append(parts, "edited") + } + if conciseBool(message["deleted"]) { + parts = append(parts, "deleted") + } + fmt.Fprintf(b, "%s- %s\n", indent, strings.Join(parts, " · ")) + + content := conciseString(message["content"]) + if conciseBool(message["deleted"]) { + content = "[deleted]" + } else if strings.TrimSpace(content) == "" { + content = "[no content]" + } + writeConciseQuote(b, indent+" ", content) + + localPaths, resourceFailures := conciseResourceSummary(message) + if len(localPaths) > 0 { + fmt.Fprintf(b, "%s resources: %s\n", indent, strings.Join(localPaths, ", ")) + } + if resourceFailures > 0 { + fmt.Fprintf(b, "%s resource_failures: %d\n", indent, resourceFailures) + } + if reactions := conciseReactionSummary(message); len(reactions) > 0 { + fmt.Fprintf(b, "%s reactions: %s\n", indent, strings.Join(reactions, ", ")) + } + if conciseBool(message["reactions_error"]) { + fmt.Fprintf(b, "%s reactions: unavailable\n", indent) + } + + parentID := conciseString(message["message_id"]) + replies := conciseMessageSlice(message["thread_replies"]) + renderedReplies := 0 + for _, child := range replies { + if parentID != "" && conciseString(child["message_id"]) == parentID { + continue + } + if renderedReplies == 0 { + fmt.Fprintf(b, "%s replies:\n", indent) + } + renderConciseMessage(b, child, indent+" ", true, stats) + renderedReplies++ + stats.Replies++ + } + if conciseBool(message["thread_has_more"]) { + fmt.Fprintf(b, "%s thread_has_more: true (thread replies incomplete)\n", indent) + } + if conciseBool(message["thread_replies_error"]) { + fmt.Fprintf(b, "%s thread_replies_error: true (thread replies unavailable)\n", indent) + } +} + +func writeConciseQuote(b *strings.Builder, indent, content string) { + content = validate.SanitizeForTerminal(content) + for _, line := range strings.Split(content, "\n") { + fmt.Fprintf(b, "%s> %s\n", indent, line) + } +} + +func collectConciseParticipants(sections []conciseChatSection) []conciseParticipant { + var participants []conciseParticipant + seen := make(map[string]struct{}) + var visit func([]map[string]interface{}, string) + visit = func(items []map[string]interface{}, parentID string) { + for _, message := range items { + messageID := conciseString(message["message_id"]) + if parentID != "" && messageID == parentID { + continue + } + sender, _ := message["sender"].(map[string]interface{}) + id := conciseString(sender["id"]) + if id != "" { + if _, exists := seen[id]; !exists { + seen[id] = struct{}{} + name := conciseString(sender["name"]) + if name == "" { + name = id + } + participants = append(participants, conciseParticipant{ + ID: id, + Name: name, + Type: conciseString(sender["sender_type"]), + OpenBotID: conciseString(sender["open_bot_id"]), + }) + } + } + visit(conciseMessageSlice(message["thread_replies"]), messageID) + } + } + for _, section := range sections { + visit(section.Messages, "") + } + return participants +} + +func conciseReactionSummary(message map[string]interface{}) []string { + reactions, _ := message["reactions"].(map[string]interface{}) + counts := conciseInterfaceSlice(reactions["counts"]) + summary := make([]string, 0, len(counts)) + for _, raw := range counts { + count, _ := raw.(map[string]interface{}) + reactionType := conciseString(count["reaction_type"]) + if reactionType == "" { + continue + } + summary = append(summary, conciseCode(fmt.Sprintf("%s x%v", reactionType, count["count"]))) + } + return summary +} + +func conciseResourceSummary(message map[string]interface{}) ([]string, int) { + resources := conciseInterfaceSlice(message["resources"]) + localPaths := make([]string, 0, len(resources)) + failures := 0 + for _, raw := range resources { + resource, _ := raw.(map[string]interface{}) + if localPath := conciseString(resource["local_path"]); localPath != "" { + localPaths = append(localPaths, conciseCode(localPath)) + } + if resourceError, exists := resource["error"]; exists { + switch value := resourceError.(type) { + case bool: + if value { + failures++ + } + case string: + if strings.TrimSpace(value) != "" { + failures++ + } + case nil: + default: + failures++ + } + } + } + return localPaths, failures +} + +func conciseMessageSlice(value interface{}) []map[string]interface{} { + switch items := value.(type) { + case []map[string]interface{}: + return items + case []interface{}: + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + if message, ok := item.(map[string]interface{}); ok { + out = append(out, message) + } + } + return out + default: + return nil + } +} + +func conciseInterfaceSlice(value interface{}) []interface{} { + switch items := value.(type) { + case []interface{}: + return items + case []map[string]interface{}: + out := make([]interface{}, len(items)) + for i := range items { + out[i] = items[i] + } + return out + default: + return nil + } +} + +func conciseSender(message map[string]interface{}) string { + sender, _ := message["sender"].(map[string]interface{}) + name := conciseString(sender["name"]) + id := conciseString(sender["id"]) + if name == "" { + name = id + } + if name == "" { + return "**unknown_sender**" + } + rendered := "**" + conciseInline(name) + "**" + if id != "" { + rendered += " (" + conciseCode(id) + ")" + } + return rendered +} + +func conciseInline(value string) string { + return conciseInlineReplacer.Replace(conciseSingleLine(value)) +} + +// conciseCode renders untrusted metadata as a single Markdown code span. The +// fence is longer than any backtick run in the value, so opaque IDs and tokens +// cannot terminate the span and inject new Markdown structure. +func conciseCode(value string) string { + value = conciseSingleLine(value) + maxRun := 0 + currentRun := 0 + for _, r := range value { + if r == '`' { + currentRun++ + maxRun = max(maxRun, currentRun) + } else { + currentRun = 0 + } + } + fence := strings.Repeat("`", maxRun+1) + if value == "" { + return fence + " " + fence + } + if strings.HasPrefix(value, "`") || strings.HasSuffix(value, "`") { + return fence + " " + value + " " + fence + } + return fence + value + fence +} + +func conciseSingleLine(value string) string { + value = validate.SanitizeForTerminal(value) + return strings.Join(strings.Fields(value), " ") +} + +func conciseString(value interface{}) string { + text, _ := value.(string) + return text +} + +func conciseBool(value interface{}) bool { + boolean, _ := value.(bool) + return boolean +} diff --git a/shortcuts/im/message_concise_test.go b/shortcuts/im/message_concise_test.go new file mode 100644 index 0000000000..a8e4ac2582 --- /dev/null +++ b/shortcuts/im/message_concise_test.go @@ -0,0 +1,261 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestRenderMessagesConciseMultiChat(t *testing.T) { + sections := []conciseChatSection{ + { + ChatID: "chat_1", + ChatName: "project_group", + ChatType: "group", + Messages: []map[string]interface{}{ + { + "message_id": "message_1", + "thread_id": "thread_1", + "msg_type": "text", + "create_time": "2026-09-01 15:17", + "content": "message_1 content", + "updated": true, + "message_app_link": "https://example.invalid/message_1", + "sender": map[string]interface{}{ + "id": "user_1", "name": "user_1", "sender_type": "user", + }, + "reactions": map[string]interface{}{ + "counts": []interface{}{ + map[string]interface{}{"reaction_type": "THUMBSUP", "count": float64(2)}, + map[string]interface{}{"reaction_type": "DONE", "count": float64(1)}, + }, + }, + "resources": []interface{}{ + map[string]interface{}{"local_path": "lark-im-resources/message_1.pdf"}, + map[string]interface{}{"error": true, "key": "hidden"}, + }, + "thread_replies": []map[string]interface{}{ + {"message_id": "message_1", "content": "duplicate root"}, + { + "message_id": "thread_reply_1", "thread_id": "thread_1", "msg_type": "post", + "create_time": "2026-09-01 15:18", "content": "reply line 1\nreply line 2", + "sender": map[string]interface{}{ + "id": "user_2", "name": "user_2", "sender_type": "app", "open_bot_id": "user_2_bot", + }, + }, + }, + }, + { + "message_id": "message_2", "reply_to": "message_1", "msg_type": "image", + "create_time": "2026-09-01 15:19", "content": "[image]", "thread_has_more": true, + "sender": map[string]interface{}{"id": "user_2", "name": "user_2", "sender_type": "app"}, + "resources": []map[string]interface{}{{"local_path": "lark-im-resources/message_2.png"}}, + }, + }, + }, + { + ChatID: "chat_2", ChatType: "p2p", ChatPartnerID: "user_3", + Messages: []map[string]interface{}{ + { + "message_id": "message_3", "msg_type": "text", "create_time": "2026-09-01 16:01", + "content": "message_3 line 1\nmessage_3 line 2", "reactions_error": true, "thread_replies_error": true, + "sender": map[string]interface{}{"id": "user_3", "name": "user_3", "sender_type": "user"}, + }, + { + "message_id": "message_4", "msg_type": "text", "create_time": "2026-09-01 16:02", + "content": "must not render", "deleted": true, + }, + }, + }, + } + + var out bytes.Buffer + if err := renderMessagesConcise(&out, conciseMessageView{ + Type: conciseMessageViewChat, Title: "Chat messages", + ChatSections: sections, HasMore: true, NextToken: "next_token_1", + }); err != nil { + t.Fatalf("renderMessagesConcise() error = %v", err) + } + + const want = `# Chat messages + +## Participants + +- user\_1 (` + "`user_1`" + `, user) +- user\_2 (` + "`user_2`" + `, app, bot_open_id: ` + "`user_2_bot`" + `) +- user\_3 (` + "`user_3`" + `, user) + +## Messages + +### Chat: project\_group + +- chat_id: ` + "`chat_1`" + ` +- chat_type: ` + "`group`" + ` + +- ` + "`2026-09-01 15:17`" + ` · **user\_1** (` + "`user_1`" + `) · ` + "`text`" + ` · message_id: ` + "`message_1`" + ` · thread_id: ` + "`thread_1`" + ` · edited + > message_1 content + resources: ` + "`lark-im-resources/message_1.pdf`" + ` + resource_failures: 1 + reactions: ` + "`THUMBSUP x2`" + `, ` + "`DONE x1`" + ` + replies: + - **Reply** · ` + "`2026-09-01 15:18`" + ` · **user\_2** (` + "`user_2`" + `) · ` + "`post`" + ` · message_id: ` + "`thread_reply_1`" + ` · thread_id: ` + "`thread_1`" + ` + > reply line 1 + > reply line 2 + +- ` + "`2026-09-01 15:19`" + ` · **user\_2** (` + "`user_2`" + `) · ` + "`image`" + ` · message_id: ` + "`message_2`" + ` · reply_to: ` + "`message_1`" + ` + > [image] + resources: ` + "`lark-im-resources/message_2.png`" + ` + thread_has_more: true (thread replies incomplete) + +### Chat: P2P + +- chat_id: ` + "`chat_2`" + ` +- chat_type: ` + "`p2p`" + ` +- chat_partner: ` + "`user_3`" + ` + +- ` + "`2026-09-01 16:01`" + ` · **user\_3** (` + "`user_3`" + `) · ` + "`text`" + ` · message_id: ` + "`message_3`" + ` + > message_3 line 1 + > message_3 line 2 + reactions: unavailable + thread_replies_error: true (thread replies unavailable) + +- ` + "`2026-09-01 16:02`" + ` · **unknown_sender** · ` + "`text`" + ` · message_id: ` + "`message_4`" + ` · deleted + > [deleted] + +## Summary + +- messages: 4 +- thread_replies: 1 +- threads: 1 +- chats: 2 +- has_more: true +- next_token: ` + "`next_token_1`" + ` +` + if got := out.String(); got != want { + t.Fatalf("concise output mismatch\n--- got ---\n%s--- want ---\n%s", got, want) + } + for _, forbidden := range []string{"duplicate root", "must not render", "hidden", "example.invalid", "message_app_link"} { + if strings.Contains(out.String(), forbidden) { + t.Fatalf("concise output contains %q:\n%s", forbidden, out.String()) + } + } +} + +func TestRenderMessagesConciseAllSectionsEmpty(t *testing.T) { + var out bytes.Buffer + if err := renderMessagesConcise(&out, conciseMessageView{ + Type: conciseMessageViewChat, + ChatSections: []conciseChatSection{{ChatID: "chat_1"}, {ChatID: "chat_2"}}, + }); err != nil { + t.Fatalf("renderMessagesConcise() error = %v", err) + } + if got := strings.Count(out.String(), "No messages found."); got != 1 { + t.Fatalf("empty marker count = %d, want 1:\n%s", got, out.String()) + } + if !strings.Contains(out.String(), "- messages: 0") { + t.Fatalf("empty output missing summary:\n%s", out.String()) + } +} + +func TestCollectConciseParticipantsDeduplicatesAcrossSections(t *testing.T) { + sender := map[string]interface{}{"id": "user_1", "name": "user_1"} + participants := collectConciseParticipants([]conciseChatSection{ + {Messages: []map[string]interface{}{{"message_id": "message_1", "sender": sender}}}, + {Messages: []map[string]interface{}{{"message_id": "message_2", "sender": sender, "mentions": []interface{}{map[string]interface{}{"id": "mentioned_only"}}}}}, + }) + if len(participants) != 1 || participants[0].ID != "user_1" { + t.Fatalf("participants = %#v, want one cross-section sender", participants) + } +} + +func TestRenderMessagesConciseSingleSectionAndSafeMetadata(t *testing.T) { + const forged = "\n## forged" + var out bytes.Buffer + err := renderMessagesConcise(&out, conciseMessageView{ + Type: conciseMessageViewChat, + Title: "Messages" + forged, + ChatSections: []conciseChatSection{{ + ChatID: "chat`" + forged, ThreadID: "thread`" + forged, + Messages: []map[string]interface{}{{ + "message_id": "message`" + forged, "content": "normal body", + "sender": map[string]interface{}{"id": "user`" + forged, "name": "\x1b[31muser" + forged + "\u202e"}, + }}, + }}, + HasMore: false, NextToken: "must-not-render", + }) + if err != nil { + t.Fatalf("renderMessagesConcise() error = %v", err) + } + got := out.String() + if strings.Contains(got, forged) || strings.Contains(got, "\x1b") || strings.Contains(got, "\u202e") { + t.Fatalf("unsafe metadata was not sanitized: %q", got) + } + for _, want := range []string{"# Messages \\#\\# forged", "chat_id: ``chat` ## forged``", "thread_id: ``thread` ## forged``", "- has_more: false"} { + if !strings.Contains(got, want) { + t.Fatalf("concise output missing %q:\n%s", want, got) + } + } + if strings.Contains(got, "next_token:") || strings.Contains(got, "must-not-render") { + t.Fatalf("completed result exposed next token:\n%s", got) + } +} + +func TestMessageListConciseFlagIsCommandScoped(t *testing.T) { + for _, shortcut := range []*common.Shortcut{&ImChatMessageList, &ImThreadsMessagesList} { + runtime, _ := newMountedIMRuntime(t, shortcut) + if runtime.Cmd.Flags().Lookup("concise") == nil { + t.Fatalf("%s is missing --concise", shortcut.Command) + } + format := runtime.Cmd.Flags().Lookup("format") + if format == nil || strings.Contains(format.Usage, "concise") { + t.Fatalf("%s changed the generic format surface: %#v", shortcut.Command, format) + } + } + + runtime, _ := newMountedIMRuntime(t, &ImChatList) + if runtime.Cmd.Flags().Lookup("concise") != nil { + t.Fatalf("%s unexpectedly exposes --concise", ImChatList.Command) + } +} + +func TestMessageListConciseDryRunKeepsJSONPreview(t *testing.T) { + tests := []struct { + name string + shortcut common.Shortcut + args []string + }{ + {name: "chat", shortcut: ImChatMessageList, args: []string{"--chat-id", "oc_test"}}, + {name: "thread", shortcut: ImThreadsMessagesList, args: []string{"--thread", "omt_test"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + factory, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{}) + parent := &cobra.Command{Use: "root", SilenceErrors: true, SilenceUsage: true} + test.shortcut.Mount(parent, factory) + parent.SetArgs(append([]string{test.shortcut.Command}, append(test.args, "--dry-run", "--concise", "--no-reactions")...)) + + if err := parent.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("dry-run stdout is not JSON: %v\n%s", err, stdout.String()) + } + if envelope["ok"] != true || envelope["dry_run"] != true { + t.Fatalf("dry-run envelope = %#v", envelope) + } + if strings.Contains(stdout.String(), "# Chat messages") || strings.Contains(stdout.String(), "# Thread messages") { + t.Fatalf("dry-run entered concise renderer:\n%s", stdout.String()) + } + }) + } +} diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 295852c51a..394dd5b532 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -58,6 +58,10 @@ The raw `sender_name` is not duplicated in output (its value is in `name`); the The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+messages-search`, `+threads-messages-list`) automatically attach a `reactions` block and (for edited messages) `update_time` to each returned message — no separate `im.reactions.batch_query` call is needed. Pass `--no-reactions` to opt out. For the full contract (output shape, the `im:message.reactions:read` scope requirement, and the "missing field ≠ fetch failure" data rules), read [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md). +### Compact message output (`--concise`) + +Some message-listing shortcuts support `--concise` for compact Markdown output. Use it when the user asks for concise output or a smaller result/file; check `--help` for availability and do not combine it with an explicit `--format`, an enabled `--json`, or a non-empty `--jq`. + ### Opt-in resource auto-download (`--download-resources`) `+chat-messages-list`, `+messages-mget`, and `+threads-messages-list` accept `--download-resources` to save eligible attachments into `./lark-im-resources/` and add a `resources` array to each message. It is off by default; stickers are not downloadable. A failed attachment is reported on that resource without aborting the message pull. Use [`+messages-resources-download`](references/lark-im-messages-resources-download.md) for one attachment. See [`references/lark-im-message-enrichment.md`](references/lark-im-message-enrichment.md) for the output contract. diff --git a/skills/lark-im/references/lark-im-chat-messages-list.md b/skills/lark-im/references/lark-im-chat-messages-list.md index e79f2cf9ce..1690741f5f 100644 --- a/skills/lark-im/references/lark-im-chat-messages-list.md +++ b/skills/lark-im/references/lark-im-chat-messages-list.md @@ -17,6 +17,9 @@ lark-cli im +chat-messages-list --chat-id oc_xxx # Get direct messages with a user (pass open_id and resolve p2p chat_id automatically) lark-cli im +chat-messages-list --user-id ou_xxx +# Read message context as compact Markdown +lark-cli im +chat-messages-list --chat-id oc_xxx --concise + # Specify a time range (ISO 8601) lark-cli im +chat-messages-list --chat-id oc_xxx --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" @@ -51,6 +54,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json | `--page-limit ` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) | | `--no-reactions` | No | Skip auto-fetching the `reactions` block | | `--download-resources` | No | Download message resources (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` and attach a `resources` block. Off by default; no extra requests when omitted | +| `--concise` | No | Render compact Markdown for message context | > Rule: `--chat-id` and `--user-id` are mutually exclusive. You must provide exactly one of them. diff --git a/skills/lark-im/references/lark-im-threads-messages-list.md b/skills/lark-im/references/lark-im-threads-messages-list.md index adddb2287a..f8a1a55e66 100644 --- a/skills/lark-im/references/lark-im-threads-messages-list.md +++ b/skills/lark-im/references/lark-im-threads-messages-list.md @@ -31,6 +31,9 @@ lark-cli im +threads-messages-list --thread omt_xxx --format pretty lark-cli im +threads-messages-list --thread omt_xxx --format table lark-cli im +threads-messages-list --thread omt_xxx --format csv +# Read thread context as compact Markdown +lark-cli im +threads-messages-list --thread omt_xxx --concise + # View as a bot lark-cli im +threads-messages-list --thread omt_xxx --as bot @@ -51,6 +54,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --dry-run | `--page-all` | No | Automatically fetch and merge subsequent pages; capped by `--page-limit` | | `--page-limit ` | No | Maximum pages fetched by `--page-all` (default 10, range 1-1000) | | `--format ` | No | Output format: `json` (default) / `pretty` / `table` / `ndjson` / `csv` | +| `--concise` | No | Render compact Markdown for thread context | | `--as ` | No | Identity type: `user` (default) / `bot` | | `--dry-run` | No | Print the request only, do not execute it |