From e86f6724d5f1e1ce1a5fd5ff87c8894bf3b1c465 Mon Sep 17 00:00:00 2001 From: huangmengxuan Date: Thu, 2 Apr 2026 15:07:13 +0800 Subject: [PATCH 1/5] feat: add --jq flag for filtering JSON output across all command types Add jq expression filtering (--jq / -q) to api, service, and shortcut commands using gojq. Includes early expression validation, mutual exclusion checks with --output and non-json --format, pagination+jq aggregation path, and comprehensive test coverage. Change-Id: I52e7d158a6264cc51f24a267b60674330e223450 Co-Authored-By: Claude Opus 4.6 --- cmd/api/api.go | 31 ++++- cmd/api/api_test.go | 173 +++++++++++++++++++++++++++ cmd/root.go | 2 + cmd/service/service.go | 31 ++++- cmd/service/service_test.go | 167 ++++++++++++++++++++++++++ go.mod | 10 +- go.sum | 17 ++- internal/client/response.go | 4 + internal/output/jq.go | 117 +++++++++++++++++++ internal/output/jq_test.go | 178 ++++++++++++++++++++++++++++ shortcuts/common/runner.go | 22 ++++ shortcuts/common/runner_jq_test.go | 181 +++++++++++++++++++++++++++++ 12 files changed, 921 insertions(+), 12 deletions(-) create mode 100644 internal/output/jq.go create mode 100644 internal/output/jq_test.go create mode 100644 shortcuts/common/runner_jq_test.go diff --git a/cmd/api/api.go b/cmd/api/api.go index 587d45271..660aad8e8 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -40,6 +40,7 @@ type APIOptions struct { PageLimit int PageDelay int Format string + JqExpr string DryRun bool } @@ -96,6 +97,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") + cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") cmd.ValidArgsFunction = func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { @@ -155,6 +157,17 @@ func apiRun(opts *APIOptions) error { if opts.PageAll && opts.Output != "" { return output.ErrValidation("--output and --page-all are mutually exclusive") } + if opts.JqExpr != "" && opts.Output != "" { + return output.ErrValidation("--jq and --output are mutually exclusive") + } + if opts.JqExpr != "" && opts.Format != "" && opts.Format != "json" { + return output.ErrValidation("--jq and --format %s are mutually exclusive", opts.Format) + } + if opts.JqExpr != "" { + if err := output.ValidateJqExpression(opts.JqExpr); err != nil { + return err + } + } request, err := buildAPIRequest(opts) if err != nil { @@ -184,7 +197,7 @@ func apiRun(opts *APIOptions) error { } if opts.PageAll { - return apiPaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut, + return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}) } @@ -195,6 +208,7 @@ func apiRun(opts *APIOptions) error { err = client.HandleResponse(resp, client.ResponseOptions{ OutputPath: opts.Output, Format: format, + JqExpr: opts.JqExpr, Out: out, ErrOut: f.IOStreams.ErrOut, }) @@ -210,7 +224,20 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl return cmdutil.PrintDryRun(f.IOStreams.Out, request, config, format) } -func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions) error { +func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error { + // When jq is set, always aggregate all pages then filter. + if jqExpr != "" { + result, err := ac.PaginateAll(ctx, request, pagOpts) + if err != nil { + return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) + } + if apiErr := client.CheckLarkResponse(result); apiErr != nil { + output.FormatValue(out, result, output.FormatJSON) + return output.MarkRaw(apiErr) + } + return output.JqFilter(out, result, jqExpr) + } + switch format { case output.FormatNDJSON, output.FormatTable, output.FormatCSV: pf := output.NewPaginatedFormatter(out, format) diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index 730aae0bc..0b01d250e 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -536,6 +536,179 @@ func TestApiCmd_PageAll_APIError_IsRaw(t *testing.T) { } } +func TestApiCmd_JqFlag_Parsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--jq", ".data"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.JqExpr != ".data" { + t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr) + } +} + +func TestApiCmd_JqFlag_ShortForm(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *APIOptions + cmd := NewCmdApi(f, func(opts *APIOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "-q", ".data"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts.JqExpr != ".data" { + t.Errorf("expected JqExpr=.data, got %s", gotOpts.JqExpr) + } +} + +func TestApiCmd_JqAndOutputConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, func(opts *APIOptions) error { + return apiRun(opts) + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --jq + --output conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-jq", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/test/jq", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + map[string]interface{}{"name": "Bob"}, + }, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") { + t.Errorf("expected jq-filtered names, got: %s", out) + } + // Should NOT contain the full envelope structure + if strings.Contains(out, `"code"`) { + t.Errorf("expected jq to filter out envelope, got: %s", out) + } +} + +func TestApiCmd_JqAndFormatConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, func(opts *APIOptions) error { + return apiRun(opts) + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --jq + --format ndjson conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestApiCmd_JqInvalidExpression(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, func(opts *APIOptions) error { + return apiRun(opts) + }) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid jq expression") + } + if !strings.Contains(err.Error(), "invalid jq expression") { + t.Errorf("expected 'invalid jq expression' error, got: %v", err) + } +} + +func TestApiCmd_PageAll_WithJq(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-pjq", AppSecret: "test-secret-pjq", Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + URL: "/open-apis/auth/v3/tenant_access_token/internal", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "tenant_access_token": "t-test-token-pjq", "expire": 7200, + }, + }) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/contact/v3/users", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "u1"}, map[string]interface{}{"id": "u2"}}, + "has_more": false, + }, + }, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "u1") || !strings.Contains(out, "u2") { + t.Errorf("expected jq-filtered ids, got: %s", out) + } + if strings.Contains(out, `"code"`) { + t.Errorf("expected jq to filter out envelope, got: %s", out) + } +} + func TestApiCmd_MethodUppercase(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, diff --git a/cmd/root.go b/cmd/root.go index 6168dbdeb..3440edb16 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -61,6 +61,8 @@ FLAGS: --page-limit max pages to fetch with --page-all (default: 10, 0 for unlimited) --page-delay delay in ms between pages (default: 200, only with --page-all) -o, --output output file path for binary responses + --jq jq expression to filter JSON output + -q shorthand for --jq --dry-run print request without executing AI AGENT SKILLS: diff --git a/cmd/service/service.go b/cmd/service/service.go index e3fceded5..480cbe610 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -109,6 +109,7 @@ type ServiceMethodOptions struct { PageLimit int PageDelay int Format string + JqExpr string DryRun bool } @@ -157,6 +158,7 @@ func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{} cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") + cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { @@ -185,6 +187,17 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { if opts.PageAll && opts.Output != "" { return output.ErrValidation("--output and --page-all are mutually exclusive") } + if opts.JqExpr != "" && opts.Output != "" { + return output.ErrValidation("--jq and --output are mutually exclusive") + } + if opts.JqExpr != "" && opts.Format != "" && opts.Format != "json" { + return output.ErrValidation("--jq and --format %s are mutually exclusive", opts.Format) + } + if opts.JqExpr != "" { + if err := output.ValidateJqExpression(opts.JqExpr); err != nil { + return err + } + } config, err := f.ResolveConfig(opts.As) if err != nil { @@ -223,7 +236,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { checkErr := scopeAwareChecker(scopes, opts.As.IsBot()) if opts.PageAll { - return servicePaginate(opts.Ctx, ac, request, format, out, f.IOStreams.ErrOut, + return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr) } @@ -234,6 +247,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { return client.HandleResponse(resp, client.ResponseOptions{ OutputPath: opts.Output, Format: format, + JqExpr: opts.JqExpr, Out: out, ErrOut: f.IOStreams.ErrOut, CheckError: checkErr, @@ -400,7 +414,20 @@ func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) e } } -func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error { +func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error { + // When jq is set, always aggregate all pages then filter. + if jqExpr != "" { + result, err := ac.PaginateAll(ctx, request, pagOpts) + if err != nil { + return output.ErrNetwork("API call failed: %v", err) + } + if apiErr := checkErr(result); apiErr != nil { + output.FormatValue(out, result, output.FormatJSON) + return apiErr + } + return output.JqFilter(out, result, jqExpr) + } + switch format { case output.FormatNDJSON, output.FormatTable, output.FormatCSV: pf := output.NewPaginatedFormatter(out, format) diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 55c0986df..d52687dbb 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -474,6 +474,173 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) { } } +// ── jq flag ── + +func TestNewCmdServiceMethod_JqFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + + var captured *ServiceMethodOptions + cmd := NewCmdServiceMethod(f, driveSpec(), + map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", + func(opts *ServiceMethodOptions) error { + captured = opts + return nil + }) + cmd.SetArgs([]string{"--jq", ".data"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured == nil { + t.Fatal("runF was not called") + } + if captured.JqExpr != ".data" { + t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr) + } +} + +func TestNewCmdServiceMethod_JqShortForm(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + + var captured *ServiceMethodOptions + cmd := NewCmdServiceMethod(f, driveSpec(), + map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", + func(opts *ServiceMethodOptions) error { + captured = opts + return nil + }) + cmd.SetArgs([]string{"-q", ".data"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if captured.JqExpr != ".data" { + t.Errorf("expected JqExpr=.data, got %s", captured.JqExpr) + } +} + +func TestServiceMethod_JqAndOutputConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --jq + --output conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-jq", AppSecret: "test-secret-jq", Brand: core.BrandFeishu, + }) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice"}, + map[string]interface{}{"name": "Bob"}, + }, + }, + }, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "Alice") || !strings.Contains(out, "Bob") { + t.Errorf("expected jq-filtered names, got: %s", out) + } + if strings.Contains(out, `"code"`) { + t.Errorf("expected jq to filter out envelope, got: %s", out) + } +} + +func TestServiceMethod_JqAndFormatConflict(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --jq + --format ndjson conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestServiceMethod_JqInvalidExpression(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + spec := map[string]interface{}{ + "name": "svc", "servicePath": "/open-apis/svc/v1", + } + method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for invalid jq expression") + } + if !strings.Contains(err.Error(), "invalid jq expression") { + t.Errorf("expected 'invalid jq expression' error, got: %v", err) + } +} + +func TestServiceMethod_PageAll_WithJq(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app-spjq", AppSecret: "test-secret-spjq", Brand: core.BrandFeishu, + }) + + reg.Register(tokenStub()) + reg.Register(&httpmock.Stub{ + URL: "/open-apis/svc/v1/items", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{"id": "s1"}, map[string]interface{}{"id": "s2"}}, + "has_more": false, + }, + }, + }) + + spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} + method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) + cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "s1") || !strings.Contains(out, "s2") { + t.Errorf("expected jq-filtered ids, got: %s", out) + } + if strings.Contains(out, `"code"`) { + t.Errorf("expected jq to filter out envelope, got: %s", out) + } +} + // ── scopeAwareChecker ── func TestScopeAwareChecker_Success(t *testing.T) { diff --git a/go.mod b/go.mod index ed41d1f0c..7855c0637 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,20 @@ module github.com/larksuite/cli -go 1.23.0 +go 1.24.0 require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/gofrs/flock v0.8.1 github.com/google/uuid v1.6.0 + github.com/itchyny/gojq v0.12.18 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/smartystreets/goconvey v1.8.1 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/net v0.33.0 - golang.org/x/sys v0.33.0 + golang.org/x/sys v0.38.0 golang.org/x/term v0.27.0 golang.org/x/text v0.23.0 ) @@ -29,6 +30,8 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -37,11 +40,12 @@ require ( github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.7 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect diff --git a/go.sum b/go.sum index b6a807a3e..c630ea9ae 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,10 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -61,6 +65,10 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= +github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= +github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= +github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -73,8 +81,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -85,7 +93,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -133,8 +140,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/client/response.go b/internal/client/response.go index 0a76d57cb..47bdb215a 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -26,6 +26,7 @@ import ( type ResponseOptions struct { OutputPath string // --output flag; "" = auto-detect Format output.Format // output format for JSON responses + JqExpr string // if set, apply jq filter instead of Format Out io.Writer // stdout ErrOut io.Writer // stderr // CheckError is called on parsed JSON results. Nil defaults to CheckLarkResponse. @@ -62,6 +63,9 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { if opts.OutputPath != "" { return saveAndPrint(resp, opts.OutputPath, opts.Out) } + if opts.JqExpr != "" { + return output.JqFilter(opts.Out, result, opts.JqExpr) + } output.FormatValue(opts.Out, result, opts.Format) return nil } diff --git a/internal/output/jq.go b/internal/output/jq.go new file mode 100644 index 000000000..26ab89d95 --- /dev/null +++ b/internal/output/jq.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "encoding/json" + "fmt" + "io" + "math/big" + + "github.com/itchyny/gojq" +) + +// JqFilter applies a jq expression to data and writes the results to w. +// Scalar values are printed raw (no quotes for strings), matching jq -r behavior. +// Complex values (maps, arrays) are printed as indented JSON. +func JqFilter(w io.Writer, data interface{}, expr string) error { + query, err := gojq.Parse(expr) + if err != nil { + return ErrValidation("invalid jq expression: %s", err) + } + code, err := gojq.Compile(query) + if err != nil { + return ErrValidation("invalid jq expression: %s", err) + } + + // Normalize data through toGeneric so typed structs become map[string]any. + normalized := toGeneric(data) + // Convert json.Number values to gojq-compatible types. + normalized = convertNumbers(normalized) + + iter := code.Run(normalized) + for { + v, ok := iter.Next() + if !ok { + break + } + if err, isErr := v.(error); isErr { + return Errorf(ExitAPI, "jq_error", "jq error: %s", err) + } + if err := writeJqValue(w, v); err != nil { + return err + } + } + return nil +} + +// ValidateJqExpression checks whether a jq expression is syntactically valid. +func ValidateJqExpression(expr string) error { + query, err := gojq.Parse(expr) + if err != nil { + return ErrValidation("invalid jq expression: %s", err) + } + _, err = gojq.Compile(query) + if err != nil { + return ErrValidation("invalid jq expression: %s", err) + } + return nil +} + +// writeJqValue writes a single jq result value to w. +// Scalars are printed raw; complex values as indented JSON. +func writeJqValue(w io.Writer, v interface{}) error { + switch val := v.(type) { + case nil: + fmt.Fprintln(w, "null") + case bool: + fmt.Fprintln(w, val) + case int: + fmt.Fprintln(w, val) + case float64: + // Use %g to avoid trailing zeros, matching jq behavior. + fmt.Fprintf(w, "%g\n", val) + case *big.Int: + fmt.Fprintln(w, val.String()) + case string: + // Raw output for strings (no quotes), matching jq -r. + fmt.Fprintln(w, val) + default: + // Complex value (map, array): indented JSON. + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return Errorf(ExitInternal, "jq_error", "failed to marshal jq result: %s", err) + } + fmt.Fprintln(w, string(b)) + } + return nil +} + +// convertNumbers recursively converts json.Number values to int or float64 +// so that gojq can process them correctly. +func convertNumbers(v interface{}) interface{} { + switch val := v.(type) { + case json.Number: + if i, err := val.Int64(); err == nil { + return int(i) + } + if f, err := val.Float64(); err == nil { + return f + } + // Fallback: return as string (shouldn't happen for valid JSON numbers). + return val.String() + case map[string]interface{}: + for k, elem := range val { + val[k] = convertNumbers(elem) + } + return val + case []interface{}: + for i, elem := range val { + val[i] = convertNumbers(elem) + } + return val + default: + return v + } +} diff --git a/internal/output/jq_test.go b/internal/output/jq_test.go new file mode 100644 index 000000000..decfcafaa --- /dev/null +++ b/internal/output/jq_test.go @@ -0,0 +1,178 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestJqFilter(t *testing.T) { + data := map[string]interface{}{ + "ok": true, + "identity": "user", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "Alice", "age": 30}, + map[string]interface{}{"name": "Bob", "age": 25}, + map[string]interface{}{"name": "Charlie", "age": 35}, + }, + "total": 3, + }, + "meta": map[string]interface{}{ + "count": 3, + }, + } + + tests := []struct { + name string + expr string + want string + wantErr bool + }{ + { + name: "identity expression", + expr: ".", + want: `"ok"`, + }, + { + name: "field access .ok", + expr: ".ok", + want: "true\n", + }, + { + name: "string field raw output", + expr: ".identity", + want: "user\n", + }, + { + name: "nested field access", + expr: ".data.total", + want: "3\n", + }, + { + name: "meta count", + expr: ".meta.count", + want: "3\n", + }, + { + name: "array iteration", + expr: ".data.items[].name", + want: "Alice\nBob\nCharlie\n", + }, + { + name: "pipe and select", + expr: `.data.items[] | select(.age > 28) | .name`, + want: "Alice\nCharlie\n", + }, + { + name: "length builtin", + expr: ".data.items | length", + want: "3\n", + }, + { + name: "keys builtin", + expr: ".data | keys", + want: "[\n \"items\",\n \"total\"\n]\n", + }, + { + name: "null for missing field", + expr: ".nonexistent", + want: "null\n", + }, + { + name: "complex value output", + expr: ".data.items[0]", + want: "{\n \"age\": 30,\n \"name\": \"Alice\"\n}\n", + }, + { + name: "invalid expression", + expr: "invalid[", + wantErr: true, + }, + { + name: "multiple outputs", + expr: ".ok, .identity", + want: "true\nuser\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + err := JqFilter(&buf, data, tt.expr) + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.name == "identity expression" { + // For identity, just verify it contains the key fields + if !strings.Contains(buf.String(), `"ok"`) { + t.Errorf("identity output missing 'ok' key") + } + return + } + if buf.String() != tt.want { + t.Errorf("got %q, want %q", buf.String(), tt.want) + } + }) + } +} + +func TestJqFilter_WithStruct(t *testing.T) { + // Test that toGeneric normalizes structs properly + type inner struct { + Name string `json:"name"` + } + data := struct { + OK bool `json:"ok"` + Item *inner `json:"item"` + }{ + OK: true, + Item: &inner{Name: "test"}, + } + + var buf bytes.Buffer + err := JqFilter(&buf, data, ".item.name") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.TrimSpace(buf.String()); got != "test" { + t.Errorf("got %q, want %q", got, "test") + } +} + +func TestValidateJqExpression(t *testing.T) { + tests := []struct { + expr string + wantErr bool + }{ + {".", false}, + {".data", false}, + {".data.items[].name", false}, + {`.data.items[] | select(.name == "Alice")`, false}, + {"length", false}, + {"keys", false}, + {"invalid[", true}, + {".foo | invalid_func", true}, + } + + for _, tt := range tests { + t.Run(tt.expr, func(t *testing.T) { + err := ValidateJqExpression(tt.expr) + if tt.wantErr && err == nil { + t.Error("expected error, got nil") + } + if !tt.wantErr && err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 5a6e2ba49..eff4ec07f 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -33,6 +33,7 @@ type RuntimeContext struct { Config *core.CliConfig Cmd *cobra.Command Format string + JqExpr string // --jq expression; empty = no filter botOnly bool // set by framework for bot-only shortcuts resolvedAs core.Identity // effective identity resolved by framework Factory *cmdutil.Factory // injected by framework @@ -419,13 +420,24 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams { // Out prints a success JSON envelope to stdout. func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) { env := output.Envelope{OK: true, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()} + if ctx.JqExpr != "" { + if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil { + fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err) + } + return + } b, _ := json.MarshalIndent(env, "", " ") fmt.Fprintln(ctx.IO().Out, string(b)) } // OutFormat prints output based on --format flag. // "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue. +// When JqExpr is set, routes through Out() regardless of format. func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) { + if ctx.JqExpr != "" { + ctx.Out(data, meta) + return + } switch ctx.Format { case "pretty": if prettyFn != nil { @@ -546,6 +558,14 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo if err := validateEnumFlags(rctx, s.Flags); err != nil { return err } + if rctx.JqExpr != "" && rctx.Format != "" && rctx.Format != "json" { + return FlagErrorf("--jq and --format %s are mutually exclusive", rctx.Format) + } + if rctx.JqExpr != "" { + if err := output.ValidateJqExpression(rctx.JqExpr); err != nil { + return err + } + } if s.Validate != nil { if err := s.Validate(rctx.ctx, rctx); err != nil { return err @@ -604,6 +624,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf if s.HasFormat { rctx.Format = rctx.Str("format") } + rctx.JqExpr, _ = cmd.Flags().GetString("jq") return rctx, nil } @@ -684,6 +705,7 @@ func registerShortcutFlags(cmd *cobra.Command, s *Shortcut) { if s.Risk == "high-risk-write" { cmd.Flags().Bool("yes", false, "confirm high-risk operation") } + cmd.Flags().StringP("jq", "q", "", "jq expression to filter JSON output") cmd.Flags().String("as", s.AuthTypes[0], "identity type: user | bot") _ = cmd.RegisterFlagCompletionFunc("as", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go new file mode 100644 index 000000000..e683111f0 --- /dev/null +++ b/shortcuts/common/runner_jq_test.go @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package common + +import ( + "bytes" + "context" + "io" + "strings" + "testing" + + lark "github.com/larksuite/oapi-sdk-go/v3" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +// newJqTestContext creates a RuntimeContext wired for jq testing. +func newJqTestContext(jqExpr, format string) (*RuntimeContext, *bytes.Buffer, *bytes.Buffer) { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("jq", "", "") + cmd.Flags().String("format", "json", "") + cmd.Flags().String("as", "bot", "") + cmd.ParseFlags(nil) + if jqExpr != "" { + cmd.Flags().Set("jq", jqExpr) + } + if format != "" { + cmd.Flags().Set("format", format) + } + + rctx := &RuntimeContext{ + ctx: context.Background(), + Config: &core.CliConfig{Brand: core.BrandFeishu}, + Cmd: cmd, + Format: format, + JqExpr: jqExpr, + resolvedAs: core.AsBot, + Factory: &cmdutil.Factory{ + IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}, + }, + } + return rctx, stdout, stderr +} + +func TestRuntimeContext_Out_WithJq(t *testing.T) { + rctx, stdout, _ := newJqTestContext(".data.name", "") + + rctx.Out(map[string]interface{}{ + "name": "Alice", + "age": 30, + }, nil) + + out := stdout.String() + if !strings.Contains(out, "Alice") { + t.Errorf("expected jq-filtered 'Alice', got: %s", out) + } + if strings.Contains(out, "age") { + t.Errorf("expected jq to filter out 'age', got: %s", out) + } +} + +func TestRuntimeContext_Out_WithJq_Identity(t *testing.T) { + rctx, stdout, _ := newJqTestContext(".ok", "") + + rctx.Out(map[string]interface{}{"key": "value"}, nil) + + out := strings.TrimSpace(stdout.String()) + if out != "true" { + t.Errorf("expected 'true' for .ok, got: %s", out) + } +} + +func TestRuntimeContext_OutFormat_WithJq_OverridesFormat(t *testing.T) { + rctx, stdout, _ := newJqTestContext(".data.items", "pretty") + + items := []interface{}{"a", "b", "c"} + rctx.OutFormat(map[string]interface{}{ + "items": items, + }, nil, func(w io.Writer) { + t.Error("prettyFn should not be called when jq is set") + }) + + out := stdout.String() + if !strings.Contains(out, "a") || !strings.Contains(out, "b") { + t.Errorf("expected jq-filtered items, got: %s", out) + } +} + +func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) { + rctx, _, stderr := newJqTestContext(".foo | invalid_func_xyz", "") + + rctx.Out(map[string]interface{}{"foo": "bar"}, nil) + + if !strings.Contains(stderr.String(), "error") { + t.Errorf("expected error on stderr for runtime jq error, got: %s", stderr.String()) + } +} + +func newTestShortcutCmd(s *Shortcut) *cobra.Command { + cmd := &cobra.Command{Use: "test-shortcut"} + cmd.SetContext(context.Background()) + registerShortcutFlags(cmd, s) + return cmd +} + +func newTestFactory() *cmdutil.Factory { + return &cmdutil.Factory{ + Config: func() (*core.CliConfig, error) { + return &core.CliConfig{ + AppID: "test", AppSecret: "test", Brand: core.BrandFeishu, + }, nil + }, + LarkClient: func() (*lark.Client, error) { + return lark.NewClient("test", "test"), nil + }, + IOStreams: &cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, + } +} + +func TestRunShortcut_JqAndFormatConflict(t *testing.T) { + s := &Shortcut{ + Service: "test", + Command: "test-shortcut", + AuthTypes: []string{"bot"}, + HasFormat: true, + Execute: func(ctx context.Context, rctx *RuntimeContext) error { + return nil + }, + } + cmd := newTestShortcutCmd(s) + cmd.Flags().Set("jq", ".data") + cmd.Flags().Set("format", "table") + cmd.Flags().Set("as", "bot") + + err := runShortcut(cmd, newTestFactory(), s, true) + if err == nil { + t.Fatal("expected error for --jq + --format table conflict") + } + if !strings.Contains(err.Error(), "mutually exclusive") { + t.Errorf("expected 'mutually exclusive' error, got: %v", err) + } +} + +func TestRunShortcut_JqInvalidExpression(t *testing.T) { + s := &Shortcut{ + Service: "test", + Command: "test-shortcut", + AuthTypes: []string{"bot"}, + Execute: func(ctx context.Context, rctx *RuntimeContext) error { + return nil + }, + } + cmd := newTestShortcutCmd(s) + cmd.Flags().Set("jq", "invalid[") + cmd.Flags().Set("as", "bot") + + err := runShortcut(cmd, newTestFactory(), s, true) + if err == nil { + t.Fatal("expected error for invalid jq expression") + } + if !strings.Contains(err.Error(), "invalid jq expression") { + t.Errorf("expected 'invalid jq expression' error, got: %v", err) + } +} + +func TestRuntimeContext_Out_WithoutJq_NormalOutput(t *testing.T) { + rctx, stdout, _ := newJqTestContext("", "") + + rctx.Out(map[string]interface{}{"key": "value"}, &output.Meta{Count: 1}) + + out := stdout.String() + if !strings.Contains(out, `"ok"`) || !strings.Contains(out, `"key"`) { + t.Errorf("expected normal JSON envelope, got: %s", out) + } +} From e05fa0d66c44b3ec894a76df674b919a3239d577 Mon Sep 17 00:00:00 2001 From: huangmengxuan Date: Thu, 2 Apr 2026 15:31:05 +0800 Subject: [PATCH 2/5] fix: correct gofmt alignment in jq_test.go struct literal Change-Id: Iad365fdb387ff808a6fca0746d85d2a3b4c2a911 Co-Authored-By: Claude Opus 4.6 --- internal/output/jq_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/output/jq_test.go b/internal/output/jq_test.go index decfcafaa..b21a639b5 100644 --- a/internal/output/jq_test.go +++ b/internal/output/jq_test.go @@ -132,8 +132,8 @@ func TestJqFilter_WithStruct(t *testing.T) { Name string `json:"name"` } data := struct { - OK bool `json:"ok"` - Item *inner `json:"item"` + OK bool `json:"ok"` + Item *inner `json:"item"` }{ OK: true, Item: &inner{Name: "test"}, From 64119be39b343c40eac506f8db2cb83c6a0f98af Mon Sep 17 00:00:00 2001 From: huangmengxuan Date: Thu, 2 Apr 2026 15:34:18 +0800 Subject: [PATCH 3/5] fix: downgrade gojq to v0.12.17 to keep Go 1.23 compatibility gojq v0.12.18 requires Go 1.24, which unnecessarily bumped the project minimum version. v0.12.17 requires only Go 1.21 and provides the same jq functionality needed. Change-Id: Ic881b8efc922a5d33ece5459a37b74df73bb554d Co-Authored-By: Claude Opus 4.6 --- go.mod | 12 +++++------- go.sum | 21 +++++++++------------ 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 7855c0637..5a3fa4c0e 100644 --- a/go.mod +++ b/go.mod @@ -1,20 +1,20 @@ module github.com/larksuite/cli -go 1.24.0 +go 1.23.0 require ( github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/gofrs/flock v0.8.1 github.com/google/uuid v1.6.0 - github.com/itchyny/gojq v0.12.18 + github.com/itchyny/gojq v0.12.17 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/smartystreets/goconvey v1.8.1 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/net v0.33.0 - golang.org/x/sys v0.38.0 + golang.org/x/sys v0.33.0 golang.org/x/term v0.27.0 golang.org/x/text v0.23.0 ) @@ -30,8 +30,6 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/clipperhouse/stringish v0.1.1 // indirect - github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect @@ -40,12 +38,12 @@ require ( github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/itchyny/timefmt-go v0.1.7 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect diff --git a/go.sum b/go.sum index c630ea9ae..91133de37 100644 --- a/go.sum +++ b/go.sum @@ -36,10 +36,6 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8 github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= -github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= -github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= -github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= -github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -65,10 +61,10 @@ github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWm github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc= -github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg= -github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA= -github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -81,8 +77,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= -github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -93,6 +89,7 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -140,8 +137,8 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= From 2d7465c88f830e67d2177e2a1cdfe752eb989c7d Mon Sep 17 00:00:00 2001 From: huangmengxuan Date: Thu, 2 Apr 2026 17:16:31 +0800 Subject: [PATCH 4/5] refactor: consolidate jq validation and pagination logic Extract ValidateJqFlags() and PaginateWithJq() shared functions to eliminate duplicated jq logic across api, service, and shortcut commands. Change-Id: I5fd46daa49eab1290a8bcdcacc80efae686d92b7 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 ++++ cmd/api/api.go | 23 +++++----------------- cmd/service/service.go | 22 +++------------------ internal/client/pagination.go | 17 ++++++++++++++++ internal/output/jq.go | 15 ++++++++++++++ internal/output/jq_test.go | 37 +++++++++++++++++++++++++++++++++++ shortcuts/common/runner.go | 9 ++------- 7 files changed, 83 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index ec525ba80..9df212e40 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,7 @@ test_scripts/ tests/mail/reports/ /log/ + +# Generated / test artifacts +internal/registry/meta_data.json +cmd/api/download.bin diff --git a/cmd/api/api.go b/cmd/api/api.go index 660aad8e8..89661b365 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -157,16 +157,8 @@ func apiRun(opts *APIOptions) error { if opts.PageAll && opts.Output != "" { return output.ErrValidation("--output and --page-all are mutually exclusive") } - if opts.JqExpr != "" && opts.Output != "" { - return output.ErrValidation("--jq and --output are mutually exclusive") - } - if opts.JqExpr != "" && opts.Format != "" && opts.Format != "json" { - return output.ErrValidation("--jq and --format %s are mutually exclusive", opts.Format) - } - if opts.JqExpr != "" { - if err := output.ValidateJqExpression(opts.JqExpr); err != nil { - return err - } + if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil { + return err } request, err := buildAPIRequest(opts) @@ -227,15 +219,10 @@ func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.Cl func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions) error { // When jq is set, always aggregate all pages then filter. if jqExpr != "" { - result, err := ac.PaginateAll(ctx, request, pagOpts) - if err != nil { - return output.MarkRaw(output.ErrNetwork("API call failed: %v", err)) - } - if apiErr := client.CheckLarkResponse(result); apiErr != nil { - output.FormatValue(out, result, output.FormatJSON) - return output.MarkRaw(apiErr) + if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil { + return output.MarkRaw(err) } - return output.JqFilter(out, result, jqExpr) + return nil } switch format { diff --git a/cmd/service/service.go b/cmd/service/service.go index 480cbe610..1a392a7ee 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -187,16 +187,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { if opts.PageAll && opts.Output != "" { return output.ErrValidation("--output and --page-all are mutually exclusive") } - if opts.JqExpr != "" && opts.Output != "" { - return output.ErrValidation("--jq and --output are mutually exclusive") - } - if opts.JqExpr != "" && opts.Format != "" && opts.Format != "json" { - return output.ErrValidation("--jq and --format %s are mutually exclusive", opts.Format) - } - if opts.JqExpr != "" { - if err := output.ValidateJqExpression(opts.JqExpr); err != nil { - return err - } + if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil { + return err } config, err := f.ResolveConfig(opts.As) @@ -417,15 +409,7 @@ func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}) e func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, pagOpts client.PaginationOptions, checkErr func(interface{}) error) error { // When jq is set, always aggregate all pages then filter. if jqExpr != "" { - result, err := ac.PaginateAll(ctx, request, pagOpts) - if err != nil { - return output.ErrNetwork("API call failed: %v", err) - } - if apiErr := checkErr(result); apiErr != nil { - output.FormatValue(out, result, output.FormatJSON) - return apiErr - } - return output.JqFilter(out, result, jqExpr) + return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr) } switch format { diff --git a/internal/client/pagination.go b/internal/client/pagination.go index 4c12979d4..ecddcc1c9 100644 --- a/internal/client/pagination.go +++ b/internal/client/pagination.go @@ -4,6 +4,7 @@ package client import ( + "context" "fmt" "io" @@ -16,6 +17,22 @@ type PaginationOptions struct { PageDelay int // ms, default 200 } +// PaginateWithJq aggregates all pages, checks for API errors, then applies a jq filter. +// If checkErr detects an error, the raw result is printed as JSON before returning the error. +func PaginateWithJq(ctx context.Context, ac *APIClient, request RawApiRequest, + jqExpr string, out io.Writer, pagOpts PaginationOptions, + checkErr func(interface{}) error) error { + result, err := ac.PaginateAll(ctx, request, pagOpts) + if err != nil { + return output.ErrNetwork("API call failed: %v", err) + } + if apiErr := checkErr(result); apiErr != nil { + output.FormatValue(out, result, output.FormatJSON) + return apiErr + } + return output.JqFilter(out, result, jqExpr) +} + func mergePagedResults(w io.Writer, results []interface{}) interface{} { if len(results) == 0 { return map[string]interface{}{} diff --git a/internal/output/jq.go b/internal/output/jq.go index 26ab89d95..5a84b5609 100644 --- a/internal/output/jq.go +++ b/internal/output/jq.go @@ -46,6 +46,21 @@ func JqFilter(w io.Writer, data interface{}, expr string) error { return nil } +// ValidateJqFlags checks --jq flag compatibility with --output and --format flags, +// and validates the jq expression syntax. Returns nil if jqExpr is empty. +func ValidateJqFlags(jqExpr, outputFlag, format string) error { + if jqExpr == "" { + return nil + } + if outputFlag != "" { + return ErrValidation("--jq and --output are mutually exclusive") + } + if format != "" && format != "json" { + return ErrValidation("--jq and --format %s are mutually exclusive", format) + } + return ValidateJqExpression(jqExpr) +} + // ValidateJqExpression checks whether a jq expression is syntactically valid. func ValidateJqExpression(expr string) error { query, err := gojq.Parse(expr) diff --git a/internal/output/jq_test.go b/internal/output/jq_test.go index b21a639b5..80300dc48 100644 --- a/internal/output/jq_test.go +++ b/internal/output/jq_test.go @@ -149,6 +149,43 @@ func TestJqFilter_WithStruct(t *testing.T) { } } +func TestValidateJqFlags(t *testing.T) { + tests := []struct { + name string + jqExpr string + outputFlag string + format string + wantErr string + }{ + {name: "empty jq is noop", jqExpr: "", outputFlag: "file.json", format: "csv", wantErr: ""}, + {name: "jq only", jqExpr: ".data", outputFlag: "", format: "", wantErr: ""}, + {name: "jq with json format", jqExpr: ".data", outputFlag: "", format: "json", wantErr: ""}, + {name: "jq and output conflict", jqExpr: ".data", outputFlag: "out.json", format: "", wantErr: "--jq and --output are mutually exclusive"}, + {name: "jq and csv conflict", jqExpr: ".data", outputFlag: "", format: "csv", wantErr: "--jq and --format csv are mutually exclusive"}, + {name: "jq and ndjson conflict", jqExpr: ".data", outputFlag: "", format: "ndjson", wantErr: "--jq and --format ndjson are mutually exclusive"}, + {name: "invalid expression", jqExpr: "invalid[", outputFlag: "", format: "", wantErr: "invalid jq expression"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateJqFlags(tt.jqExpr, tt.outputFlag, tt.format) + if tt.wantErr == "" { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Errorf("expected error containing %q, got nil", tt.wantErr) + return + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr) + } + }) + } +} + func TestValidateJqExpression(t *testing.T) { tests := []struct { expr string diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index eff4ec07f..eae49dc1f 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -558,13 +558,8 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo if err := validateEnumFlags(rctx, s.Flags); err != nil { return err } - if rctx.JqExpr != "" && rctx.Format != "" && rctx.Format != "json" { - return FlagErrorf("--jq and --format %s are mutually exclusive", rctx.Format) - } - if rctx.JqExpr != "" { - if err := output.ValidateJqExpression(rctx.JqExpr); err != nil { - return err - } + if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil { + return err } if s.Validate != nil { if err := s.Validate(rctx.ctx, rctx); err != nil { From bf95808c4cefe4edad19ab89d2a711fbe2668bfd Mon Sep 17 00:00:00 2001 From: huangmengxuan Date: Thu, 2 Apr 2026 17:39:35 +0800 Subject: [PATCH 5/5] fix: reject --jq for non-JSON responses and propagate shortcut jq errors - HandleResponse now returns a validation error when --jq is used with a non-JSON Content-Type instead of silently falling through to binary save. - Shortcut runtime jq errors are captured in RuntimeContext.outputErr and propagated as the command exit code, matching api/service behavior. Change-Id: Id4b5d2376e7613e1d247ec63c042bc3129d94eb0 Co-Authored-By: Claude Opus 4.6 --- internal/client/response.go | 3 +++ internal/client/response_test.go | 17 +++++++++++++++++ shortcuts/common/runner.go | 9 ++++++++- shortcuts/common/runner_jq_test.go | 20 ++++++++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/internal/client/response.go b/internal/client/response.go index 47bdb215a..db34400b1 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -71,6 +71,9 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error { } // Non-JSON (binary) responses. + if opts.JqExpr != "" { + return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct) + } if opts.OutputPath != "" { return saveAndPrint(resp, opts.OutputPath, opts.Out) } diff --git a/internal/client/response_test.go b/internal/client/response_test.go index 8bfc6f16b..0de09f977 100644 --- a/internal/client/response_test.go +++ b/internal/client/response_test.go @@ -319,6 +319,23 @@ func TestHandleResponse_200TextPlain_SavesFile(t *testing.T) { } } +func TestHandleResponse_BinaryWithJq_RejectsNonJSON(t *testing.T) { + resp := newApiResp([]byte("PNG DATA"), map[string]string{"Content-Type": "image/png"}) + + var out, errOut bytes.Buffer + err := HandleResponse(resp, ResponseOptions{ + JqExpr: ".data", + Out: &out, + ErrOut: &errOut, + }) + if err == nil { + t.Fatal("expected error when --jq is used with non-JSON response") + } + if !strings.Contains(err.Error(), "--jq requires a JSON response") { + t.Errorf("expected '--jq requires a JSON response' error, got: %v", err) + } +} + func TestHandleResponse_403JSON_CheckLarkResponse(t *testing.T) { body := []byte(`{"code":99991400,"msg":"invalid token"}`) resp := newApiRespWithStatus(403, body, map[string]string{"Content-Type": "application/json"}) diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index eae49dc1f..36a0d4d2b 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -34,6 +34,7 @@ type RuntimeContext struct { Cmd *cobra.Command Format string JqExpr string // --jq expression; empty = no filter + outputErr error // deferred error from Out()/OutFormat() jq filtering botOnly bool // set by framework for bot-only shortcuts resolvedAs core.Identity // effective identity resolved by framework Factory *cmdutil.Factory // injected by framework @@ -423,6 +424,9 @@ func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) { if ctx.JqExpr != "" { if err := output.JqFilter(ctx.IO().Out, env, ctx.JqExpr); err != nil { fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err) + if ctx.outputErr == nil { + ctx.outputErr = err + } } return } @@ -577,7 +581,10 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo } } - return s.Execute(rctx.ctx, rctx) + if err := s.Execute(rctx.ctx, rctx); err != nil { + return err + } + return rctx.outputErr } func resolveShortcutIdentity(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut) (core.Identity, error) { diff --git a/shortcuts/common/runner_jq_test.go b/shortcuts/common/runner_jq_test.go index e683111f0..cce144f41 100644 --- a/shortcuts/common/runner_jq_test.go +++ b/shortcuts/common/runner_jq_test.go @@ -169,6 +169,26 @@ func TestRunShortcut_JqInvalidExpression(t *testing.T) { } } +func TestRunShortcut_JqRuntimeError_PropagatesError(t *testing.T) { + s := &Shortcut{ + Service: "test", + Command: "test-shortcut", + AuthTypes: []string{"bot"}, + Execute: func(ctx context.Context, rctx *RuntimeContext) error { + rctx.Out(map[string]interface{}{"foo": "bar"}, nil) + return nil + }, + } + cmd := newTestShortcutCmd(s) + cmd.Flags().Set("jq", ".foo | invalid_func_xyz") + cmd.Flags().Set("as", "bot") + + err := runShortcut(cmd, newTestFactory(), s, true) + if err == nil { + t.Fatal("expected error from jq runtime failure to propagate") + } +} + func TestRuntimeContext_Out_WithoutJq_NormalOutput(t *testing.T) { rctx, stdout, _ := newJqTestContext("", "")