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 587d45271..89661b365 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,9 @@ func apiRun(opts *APIOptions) error { if opts.PageAll && opts.Output != "" { return output.ErrValidation("--output and --page-all are mutually exclusive") } + if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil { + return err + } request, err := buildAPIRequest(opts) if err != nil { @@ -184,7 +189,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 +200,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 +216,15 @@ 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 != "" { + if err := client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, client.CheckLarkResponse); err != nil { + return output.MarkRaw(err) + } + return nil + } + 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..1a392a7ee 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,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { if opts.PageAll && opts.Output != "" { return output.ErrValidation("--output and --page-all are mutually exclusive") } + if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil { + return err + } config, err := f.ResolveConfig(opts.As) if err != nil { @@ -223,7 +228,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 +239,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 +406,12 @@ 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 != "" { + return client.PaginateWithJq(ctx, ac, request, jqExpr, out, pagOpts, checkErr) + } + 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..5a3fa4c0e 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( 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.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 @@ -37,6 +38,7 @@ 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.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 diff --git a/go.sum b/go.sum index b6a807a3e..91133de37 100644 --- a/go.sum +++ b/go.sum @@ -61,6 +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.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= 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/client/response.go b/internal/client/response.go index 0a76d57cb..db34400b1 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,11 +63,17 @@ 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 } // 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/internal/output/jq.go b/internal/output/jq.go new file mode 100644 index 000000000..5a84b5609 --- /dev/null +++ b/internal/output/jq.go @@ -0,0 +1,132 @@ +// 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 +} + +// 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) + 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..80300dc48 --- /dev/null +++ b/internal/output/jq_test.go @@ -0,0 +1,215 @@ +// 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 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 + 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..36a0d4d2b 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -33,6 +33,8 @@ type RuntimeContext struct { Config *core.CliConfig 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 @@ -419,13 +421,27 @@ 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) + if ctx.outputErr == nil { + ctx.outputErr = 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 +562,9 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo if err := validateEnumFlags(rctx, s.Flags); 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 { return err @@ -562,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) { @@ -604,6 +626,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 +707,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..cce144f41 --- /dev/null +++ b/shortcuts/common/runner_jq_test.go @@ -0,0 +1,201 @@ +// 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 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("", "") + + 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) + } +}