From 7fcc94068177b262dadd18b7849a62d9c15d6ed5 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 20 Aug 2026 07:29:52 -0400 Subject: [PATCH 1/2] Add built-in jq filtering --- .surface | 2 + README.md | 15 ++- go.mod | 4 +- go.sum | 4 + internal/cmd/help.go | 1 + internal/cmd/help_test.go | 1 + internal/cmd/jq_test.go | 83 ++++++++++++++ internal/cmd/root.go | 62 +++++++++-- internal/output/errors.go | 40 ++++++- internal/output/writer.go | 153 +++++++++++++++++++++++++- internal/output/writer_test.go | 193 +++++++++++++++++++++++++++++++++ skills/hey/SKILL.md | 14 ++- 12 files changed, 554 insertions(+), 18 deletions(-) create mode 100644 internal/cmd/jq_test.go diff --git a/.surface b/.surface index 08838b4a..00e6c63f 100644 --- a/.surface +++ b/.surface @@ -5,12 +5,14 @@ hey --base-url hey --count hey --html hey --ids-only +hey --jq hey --json hey --markdown hey --quiet hey --stats hey --styled hey --verbose +hey --version hey accounts hey accounts list hey accounts use diff --git a/README.md b/README.md index 5173e1cf..c148d465 100644 --- a/README.md +++ b/README.md @@ -98,8 +98,19 @@ Press Shift+O to open Contacts. Use Enter to view a contact, `a` to add, `e` to ## CLI Commands -All commands support `--json` for raw JSON output, `--base-url` to override the server URL, -and `--account ` to select a linked mail account. +Structured data commands support `--json` for full output and `--jq ''` to +filter that output without an external `jq` binary. `--jq` implies `--json` and filters +the full success envelope; combine it with `--quiet` to filter result data directly. +Errors retain their complete structured envelope. Commands with dedicated raw output +(`auth token`, `completion`, `skill`, `tui`, and `--version`) reject `--jq`. + +Use `--base-url` to override the server URL and `--account ` to select a linked +mail account. + +```bash +hey boxes --jq '.data[] | {id, name}' +hey boxes --quiet --jq '.[].id' +``` ### Email diff --git a/go.mod b/go.mod index c86dab80..2ca522dc 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( charm.land/bubbletea/v2 v2.0.8 charm.land/lipgloss/v2 v2.0.6 github.com/basecamp/hey-sdk/go v0.6.0 + github.com/charmbracelet/x/ansi v0.11.8 + github.com/itchyny/gojq v0.12.19 github.com/mattn/go-runewidth v0.0.27 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 @@ -22,7 +24,6 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect - github.com/charmbracelet/x/ansi v0.11.8 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect @@ -32,6 +33,7 @@ require ( github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/timefmt-go v0.1.8 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/oapi-codegen/runtime v1.6.0 // indirect diff --git a/go.sum b/go.sum index 79c70dd7..497a1d34 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.19 h1:ttXA0XCLEMoaLOz5lSeFOZ6u6Q3QxmG46vfgI4O0DEs= +github.com/itchyny/gojq v0.12.19/go.mod h1:5galtVPDywX8SPSOrqjGxkBeDhSxEW1gSxoy7tn1iZY= +github.com/itchyny/timefmt-go v0.1.8 h1:1YEo1JvfXeAHKdjelbYr/uCuhkybaHCeTkH8Bo791OI= +github.com/itchyny/timefmt-go v0.1.8/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= diff --git a/internal/cmd/help.go b/internal/cmd/help.go index 015d4a6f..732ad4b9 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -104,6 +104,7 @@ func renderRootHelp(w io.Writer, cmd *cobra.Command) { flags := []flagEntry{ {"", "--account", "Select a linked mail account ID or all"}, {"", "--json", "Output JSON with metadata"}, + {"", "--jq", "Filter JSON with a built-in jq expression"}, {"", "--markdown", "Output as Markdown"}, {"", "--quiet", "Output result data only"}, {"-v", "--verbose", "Show request details"}, diff --git a/internal/cmd/help_test.go b/internal/cmd/help_test.go index 5266f4e0..045d5019 100644 --- a/internal/cmd/help_test.go +++ b/internal/cmd/help_test.go @@ -129,6 +129,7 @@ AUTH & CONFIG FLAGS --account Select a linked mail account ID or all --json Output JSON with metadata + --jq Filter JSON with a built-in jq expression --markdown Output as Markdown --quiet Output result data only -v, --verbose Show request details diff --git a/internal/cmd/jq_test.go b/internal/cmd/jq_test.go new file mode 100644 index 00000000..905d5dcf --- /dev/null +++ b/internal/cmd/jq_test.go @@ -0,0 +1,83 @@ +package cmd + +import ( + "bytes" + "strings" + "testing" +) + +func TestRootRegistersJQFlag(t *testing.T) { + flag := newRootCmd().PersistentFlags().Lookup("jq") + if flag == nil { + t.Fatal("expected --jq flag") + } + if flag.Usage != "Filter JSON with a built-in jq expression" { + t.Errorf("unexpected --jq help: %q", flag.Usage) + } +} + +func TestValidateJQFlags(t *testing.T) { + tests := []struct { + name string + args []string + filter string + ids bool + count bool + want string + }{ + {name: "empty", args: []string{"auth", "status"}}, + {name: "valid", args: []string{"auth", "status"}, filter: ".data[].id"}, + {name: "invalid", args: []string{"auth", "status"}, filter: ".[invalid", want: "invalid --jq expression"}, + {name: "ids conflict", args: []string{"auth", "status"}, filter: ".data", ids: true, want: "cannot use --jq with --ids-only"}, + {name: "count conflict", args: []string{"auth", "status"}, filter: ".data", count: true, want: "cannot use --jq with --count"}, + {name: "root app", filter: ".", want: "--jq is not supported by the interactive app"}, + {name: "auth token", args: []string{"auth", "token"}, filter: ".", want: "--jq is not supported by the auth token command"}, + {name: "completion", args: []string{"completion"}, filter: ".", want: "--jq is not supported by the completion command"}, + {name: "skill display", args: []string{"skill"}, filter: ".", want: "--jq is not supported by the skill display command"}, + {name: "tui", args: []string{"tui"}, filter: ".", want: "--jq is not supported by the interactive app"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := newRootCmd() + cmd, _, err := root.Find(tt.args) + if err != nil { + t.Fatal(err) + } + err = validateJQFlags(cmd, tt.filter, tt.ids, tt.count) + if tt.want == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("expected %q, got %v", tt.want, err) + } + }) + } +} + +func TestRootVersionSupportsRawOutputAndRejectsJQ(t *testing.T) { + t.Run("version", func(t *testing.T) { + root := newRootCmd() + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs([]string{"--version"}) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(stdout.String(), "hey version ") { + t.Errorf("unexpected version output: %q", stdout.String()) + } + }) + + t.Run("jq", func(t *testing.T) { + root := newRootCmd() + root.SetArgs([]string{"--version", "--jq", "."}) + err := root.Execute() + if err == nil || err.Error() != "--jq is not supported by the version command" { + t.Fatalf("unexpected error: %v", err) + } + }) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index bab969ed..5d0270e5 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -29,6 +29,8 @@ var ( styledFlag bool agentFlag bool statsFlag bool + jqFlag string + versionFlag bool verboseFlag int baseURL string accountFlag string @@ -54,12 +56,22 @@ func newRootCmd() *cobra.Command { SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - format := output.FormatFromFlags(jsonFlag, quietFlag, idsOnly, countFlag, markdownF, styledFlag, agentFlag) + format := output.FormatFromFlags(jsonFlag || jqFlag != "", quietFlag, idsOnly, countFlag, markdownF, styledFlag, agentFlag) writer = output.New(output.Options{ - Format: format, - Stdout: cmd.OutOrStdout(), - Stderr: cmd.ErrOrStderr(), + Format: format, + Stdout: cmd.OutOrStdout(), + Stderr: cmd.ErrOrStderr(), + JQFilter: jqFlag, }) + if versionFlag { + if jqFlag != "" { + return output.ErrJQNotSupported("the version command") + } + return nil + } + if err := validateJQFlags(cmd, jqFlag, idsOnly, countFlag); err != nil { + return err + } var err error cfg, err = config.Load() @@ -101,6 +113,10 @@ func newRootCmd() *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { + if versionFlag { + fmt.Fprintf(cmd.OutOrStdout(), "hey version %s\n", version.Version) + return nil + } if !stdinIsTerminal() || !stdoutIsTerminal() { return cmd.Help() } @@ -126,9 +142,8 @@ func newRootCmd() *cobra.Command { root.PersistentFlags().StringVar(&accountFlag, "account", "", "Select a linked mail account ID or all") root.PersistentFlags().CountVarP(&verboseFlag, "verbose", "v", "Show request details") root.PersistentFlags().BoolVar(&statsFlag, "stats", false, "Include request stats in response meta") - - root.Version = version.Version - root.SetVersionTemplate("hey version {{.Version}}\n") + root.PersistentFlags().StringVar(&jqFlag, "jq", "", "Filter JSON with a built-in jq expression") + root.Flags().BoolVar(&versionFlag, "version", false, "Show version") // Override help with styled categories and curated flags root.SetHelpFunc(customHelpFunc(root.HelpFunc())) @@ -191,7 +206,8 @@ func Execute() { err = normalizeCobraError(err) if writer == nil { writer = output.New(output.Options{ - Format: output.FormatFromFlags(jsonFlag, quietFlag, idsOnly, countFlag, markdownF, styledFlag, agentFlag), + Format: output.FormatFromFlags(jsonFlag || jqFlag != "", quietFlag, idsOnly, countFlag, markdownF, styledFlag, agentFlag), + JQFilter: jqFlag, }) } if writer.IsStyled() && strings.HasPrefix(err.Error(), "Usage:") { @@ -203,6 +219,36 @@ func Execute() { } } +func validateJQFlags(cmd *cobra.Command, filter string, ids, count bool) error { + if err := output.ValidateJQFilter(filter); err != nil { + return err + } + if filter == "" { + return nil + } + if ids { + return output.ErrJQConflict("--ids-only") + } + if count { + return output.ErrJQConflict("--count") + } + + switch cmd.CommandPath() { + case "hey": + return output.ErrJQNotSupported("the interactive app") + case "hey auth token": + return output.ErrJQNotSupported("the auth token command") + case "hey completion": + return output.ErrJQNotSupported("the completion command") + case "hey skill": + return output.ErrJQNotSupported("the skill display command") + case "hey tui": + return output.ErrJQNotSupported("the interactive app") + default: + return nil + } +} + func requireAuth() error { if !authMgr.IsAuthenticated() { return output.ErrAuth("not logged in — run `hey auth login` first") diff --git a/internal/output/errors.go b/internal/output/errors.go index ee9d6aff..0040ec3c 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -1,6 +1,10 @@ package output -import "github.com/basecamp/hey-cli/internal/apierr" +import ( + "fmt" + + "github.com/basecamp/hey-cli/internal/apierr" +) // Error is a typed error with a code, message, and optional fields. // Alias for apierr.Error so that cmd-layer code can use output.Error @@ -19,3 +23,37 @@ var ( ErrAmbiguous = apierr.ErrAmbiguous AsError = apierr.AsError ) + +// ErrJQValidation reports an invalid built-in jq expression. +func ErrJQValidation(cause error) *Error { + return &Error{ + Code: "usage", + Message: fmt.Sprintf("invalid --jq expression: %s", cause), + Cause: cause, + } +} + +// ErrJQNotSupported reports a command that produces a dedicated raw format. +func ErrJQNotSupported(command string) *Error { + return &Error{ + Code: "usage", + Message: fmt.Sprintf("--jq is not supported by %s", command), + } +} + +// ErrJQConflict reports an output flag that cannot be combined with --jq. +func ErrJQConflict(flag string) *Error { + return &Error{ + Code: "usage", + Message: fmt.Sprintf("cannot use --jq with %s", flag), + } +} + +// ErrJQRuntime reports a failure while evaluating a built-in jq expression. +func ErrJQRuntime(cause error) *Error { + return &Error{ + Code: "usage", + Message: fmt.Sprintf("jq filter error: %s", cause), + Cause: cause, + } +} diff --git a/internal/output/writer.go b/internal/output/writer.go index fa4b6c59..e7795c07 100644 --- a/internal/output/writer.go +++ b/internal/output/writer.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" + "github.com/charmbracelet/x/ansi" + "github.com/itchyny/gojq" "golang.org/x/term" ) @@ -26,13 +28,15 @@ const ( ) type Options struct { - Format Format - Stdout io.Writer - Stderr io.Writer + Format Format + Stdout io.Writer + Stderr io.Writer + JQFilter string } type Writer struct { opts Options + jq *gojq.Code } func New(opts Options) *Writer { @@ -42,7 +46,11 @@ func New(opts Options) *Writer { if opts.Stderr == nil { opts.Stderr = os.Stderr } - return &Writer{opts: opts} + w := &Writer{opts: opts} + if opts.JQFilter != "" { + w.jq, _ = compileJQ(opts.JQFilter) + } + return w } func (w *Writer) EffectiveFormat() Format { @@ -61,6 +69,16 @@ func (w *Writer) IsStyled() bool { func (w *Writer) OK(data any, opts ...ResponseOption) error { format := w.EffectiveFormat() + if w.opts.JQFilter != "" { + resp := Response{OK: true, Data: data} + for _, opt := range opts { + opt(&resp) + } + if format == FormatQuiet { + return w.writeJQ(resp.Data) + } + return w.writeJQ(resp) + } switch format { case FormatQuiet: @@ -111,6 +129,133 @@ func (w *Writer) writeJSON(data any, opts ...ResponseOption) error { return enc.Encode(resp) } +func (w *Writer) writeJQ(target any) error { + code := w.jq + if code == nil { + var err error + code, err = compileJQ(w.opts.JQFilter) + if err != nil { + return ErrJQValidation(err) + } + } + + raw, err := json.Marshal(target) + if err != nil { + return ErrJQRuntime(fmt.Errorf("encode input: %w", err)) + } + input, err := NormalizeJSONNumbers(raw) + if err != nil { + return ErrJQRuntime(fmt.Errorf("decode input: %w", err)) + } + + iter := code.Run(input) + for { + result, ok := iter.Next() + if !ok { + return nil + } + if err, ok := result.(error); ok { + return ErrJQRuntime(err) + } + if err := w.writeJQResult(result, isTTY(w.opts.Stdout)); err != nil { + return err + } + } +} + +func (w *Writer) writeJQResult(result any, tty bool) error { + if tty { + result = sanitizeJSONValue(result) + } + if text, ok := result.(string); ok { + _, err := fmt.Fprintln(w.opts.Stdout, text) + return err + } + + raw, err := json.MarshalIndent(result, "", " ") + if err != nil { + return ErrJQRuntime(fmt.Errorf("encode result: %w", err)) + } + _, err = fmt.Fprintln(w.opts.Stdout, string(raw)) + return err +} + +func compileJQ(filter string) (*gojq.Code, error) { + query, err := gojq.Parse(filter) + if err != nil { + return nil, err + } + return gojq.Compile(query, gojq.WithEnvironLoader(os.Environ)) +} + +// ValidateJQFilter confirms that a built-in jq expression is ready to run. +func ValidateJQFilter(filter string) error { + if filter == "" { + return nil + } + if _, err := compileJQ(filter); err != nil { + return ErrJQValidation(err) + } + return nil +} + +func sanitizeJSONValue(value any) any { + switch value := value.(type) { + case string: + return sanitizeTerminal(value) + case []any: + result := make([]any, len(value)) + for i, item := range value { + result[i] = sanitizeJSONValue(item) + } + return result + case map[string]any: + return sanitizeJSONMap(value) + default: + return value + } +} + +func sanitizeJSONMap(value map[string]any) map[string]any { + result := make(map[string]any, len(value)) + var changed []string + for key, item := range value { + if sanitizeTerminal(key) == key { + result[key] = sanitizeJSONValue(item) + } else { + changed = append(changed, key) + } + } + + sort.Strings(changed) + for _, key := range changed { + quoted := strconv.Quote(key) + name := quoted[1 : len(quoted)-1] + for { + if _, exists := result[name]; !exists { + break + } + name = strconv.Quote(name) + } + result[name] = sanitizeJSONValue(value[key]) + } + return result +} + +func sanitizeTerminal(value string) string { + value = ansi.Strip(value) + return strings.Map(func(r rune) rune { + switch { + case r == '\n' || r == '\t': + return r + case r < 0x20 || r == 0x7f || (r >= 0x80 && r <= 0x9f): + return -1 + default: + return r + } + }, value) +} + func (w *Writer) writeQuiet(data any) error { enc := json.NewEncoder(w.opts.Stdout) enc.SetIndent("", " ") diff --git a/internal/output/writer_test.go b/internal/output/writer_test.go index e665b158..87b1b47b 100644 --- a/internal/output/writer_test.go +++ b/internal/output/writer_test.go @@ -53,6 +53,199 @@ func TestWriterOK_Quiet(t *testing.T) { } } +func TestWriterOK_JQFiltersEnvelope(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Format: FormatJSON, Stdout: &buf, JQFilter: ".data.name"}) + + if err := w.OK(map[string]any{"name": "Jane"}); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "Jane\n" { + t.Errorf("expected scalar text, got %q", got) + } +} + +func TestWriterOK_JQFiltersQuietData(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Format: FormatQuiet, Stdout: &buf, JQFilter: ".[0].id"}) + + if err := w.OK([]map[string]any{{"id": 42}}); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "42\n" { + t.Errorf("expected data-only result, got %q", got) + } +} + +func TestWriterOK_JQFormatsObjectsAndArrays(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Format: FormatJSON, Stdout: &buf, JQFilter: ".data"}) + + if err := w.OK(map[string]any{"names": []string{"Jane", "Lin"}}); err != nil { + t.Fatal(err) + } + const expected = "{\n \"names\": [\n \"Jane\",\n \"Lin\"\n ]\n}\n" + if got := buf.String(); got != expected { + t.Errorf("expected formatted JSON:\n%s\ngot:\n%s", expected, got) + } +} + +func TestWriterOK_JQWritesMultipleAndEmptyResults(t *testing.T) { + t.Run("multiple", func(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Format: FormatJSON, Stdout: &buf, JQFilter: ".data[].name"}) + if err := w.OK([]map[string]any{{"name": "Jane"}, {"name": "Lin"}}); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "Jane\nLin\n" { + t.Errorf("unexpected results: %q", got) + } + }) + + t.Run("empty", func(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Format: FormatJSON, Stdout: &buf, JQFilter: ".data[] | select(.active)"}) + if err := w.OK([]map[string]any{{"active": false}}); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "" { + t.Errorf("expected no output, got %q", got) + } + }) +} + +func TestWriterOK_JQPreservesLargeIntegers(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Format: FormatJSON, Stdout: &buf, JQFilter: ".data.id"}) + + if err := w.OK(map[string]any{"id": json.Number("1234567890123456789")}); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "1234567890123456789\n" { + t.Errorf("integer precision changed: %q", got) + } +} + +func TestWriterOK_JQUsesEnvironmentVariables(t *testing.T) { + t.Setenv("HEY_JQ_TEST_NAME", "Jane") + var buf bytes.Buffer + w := New(Options{Format: FormatJSON, Stdout: &buf, JQFilter: "env.HEY_JQ_TEST_NAME"}) + + if err := w.OK(nil); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "Jane\n" { + t.Errorf("expected environment value, got %q", got) + } +} + +func TestWriterOK_JQReportsExpressionErrors(t *testing.T) { + t.Run("invalid", func(t *testing.T) { + w := New(Options{Format: FormatJSON, Stdout: &bytes.Buffer{}, JQFilter: ".[invalid"}) + err := w.OK(nil) + if err == nil || !strings.Contains(err.Error(), "invalid --jq expression") { + t.Fatalf("expected validation error, got %v", err) + } + if AsError(err).Code != "usage" { + t.Errorf("expected usage code, got %q", AsError(err).Code) + } + }) + + t.Run("runtime", func(t *testing.T) { + w := New(Options{Format: FormatJSON, Stdout: &bytes.Buffer{}, JQFilter: ".data.id[]"}) + err := w.OK(map[string]any{"id": 42}) + if err == nil || !strings.Contains(err.Error(), "jq filter error") { + t.Fatalf("expected runtime error, got %v", err) + } + if AsError(err).Code != "usage" { + t.Errorf("expected usage code, got %q", AsError(err).Code) + } + }) +} + +func TestWriterErr_JQKeepsErrorEnvelope(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Format: FormatJSON, Stderr: &buf, JQFilter: ".data.id"}) + + w.Err(ErrNotFound("topic", "123")) + + var resp ErrorResponse + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("invalid error JSON: %v", err) + } + if resp.Code != "not_found" { + t.Errorf("expected unfiltered error, got %#v", resp) + } +} + +func TestWriterJQSanitizesTerminalResults(t *testing.T) { + const unsafe = "safe\x1b]8;;https://example.com\x07link\x1b]8;;\x07\u009b31m!" + + t.Run("string", func(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Stdout: &buf}) + if err := w.writeJQResult(unsafe, true); err != nil { + t.Fatal(err) + } + if got := buf.String(); got != "safelink31m!\n" { + t.Errorf("unexpected sanitized string: %q", got) + } + }) + + t.Run("compound", func(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Stdout: &buf}) + if err := w.writeJQResult(map[string]any{unsafe: []any{unsafe}}, true); err != nil { + t.Fatal(err) + } + if strings.ContainsAny(buf.String(), "\x1b\u009b") { + t.Errorf("terminal controls remain in compound output: %q", buf.String()) + } + if !strings.Contains(buf.String(), `"safelink31m!"`) { + t.Errorf("sanitized value missing: %q", buf.String()) + } + }) + + t.Run("key collisions preserve every field", func(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Stdout: &buf}) + input := map[string]any{ + "title": "clean", + "\\u001b[31mtitle": "literal", + "\x1b[31mtitle": "escaped", + } + if err := w.writeJQResult(input, true); err != nil { + t.Fatal(err) + } + var result map[string]any + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + t.Fatal(err) + } + if len(result) != len(input) { + t.Fatalf("sanitization dropped a field: %#v", result) + } + if result["title"] != "clean" || result["\\u001b[31mtitle"] != "literal" { + t.Errorf("sanitization replaced a clean field: %#v", result) + } + for key := range result { + if strings.ContainsRune(key, '\x1b') { + t.Errorf("terminal escape remains in key %q", key) + } + } + }) + + t.Run("pipe preserves bytes", func(t *testing.T) { + var buf bytes.Buffer + w := New(Options{Stdout: &buf}) + if err := w.writeJQResult(unsafe, false); err != nil { + t.Fatal(err) + } + if got := strings.TrimSuffix(buf.String(), "\n"); got != unsafe { + t.Errorf("piped bytes changed: %q", got) + } + }) +} + func TestWriterOK_Count(t *testing.T) { var buf bytes.Buffer w := New(Options{Format: FormatCount, Stdout: &buf}) diff --git a/skills/hey/SKILL.md b/skills/hey/SKILL.md index f7f07ba7..acdfa342 100644 --- a/skills/hey/SKILL.md +++ b/skills/hey/SKILL.md @@ -98,12 +98,22 @@ CLI for HEY: mailboxes, email threads, contacts, replies, compose, calendars, to **MUST follow these rules:** -1. **Always use `--json`** for structured, predictable output +1. **Choose the right structured output** — use `--jq ''` to filter or extract fields and `--json` for the full response. Never pipe to an external `jq`; `--jq` is built in and implies `--json`. 2. **Authentication required** for all data commands — run `hey auth login` first 3. **HTML output** is available via `--html` for commands that return HTML content 4. **Linked mail accounts share one login** — use `hey accounts list --json`, then `--account ` when a task must target one account 5. **Local HEY configuration requires human trust** — never run `hey config trust-local` without the user's explicit approval +## Output Filtering + +`--jq` filters the full JSON success envelope, so result data is under `.data`. String results print as plain text; objects and arrays print as formatted JSON. Use `--quiet --jq` when the expression should run against result data directly. Errors retain their complete structured envelope. Commands with dedicated raw output (`auth token`, `completion`, `skill`, `tui`, and `--version`) reject `--jq`. + +```bash +hey boxes --jq '.data[] | {id, name}' +hey search "quarterly planning" --jq '.data[].id' +hey boxes --quiet --jq '.[].name' +``` + ## Quick Reference | Task | Command | @@ -355,7 +365,7 @@ hey calendars --json # List calendars (returns array of hey recordings 123 --json # List events in calendar ``` -**Response format:** `hey recordings` returns recordings grouped by type (e.g. `{"Calendar::Event": [...], "Calendar::Habit": [...], "Calendar::Todo": [...]}`). Each recording has: `id`, `title`, `starts_at`, `ends_at`, `all_day`, `recurring`, `starts_at_time_zone`. Access by type key in jq, e.g. `.["Calendar::Event"]`. +**Response format:** `hey recordings` returns recordings grouped by type (e.g. `{"Calendar::Event": [...], "Calendar::Habit": [...], "Calendar::Todo": [...]}`). Each recording has: `id`, `title`, `starts_at`, `ends_at`, `all_day`, `recurring`, `starts_at_time_zone`. Access a type with the built-in filter, e.g. `hey recordings 123 --quiet --jq '.["Calendar::Event"]'`. ### Todos From ac9d4b162570b65ecad8eca7e379a55b3c9c1834 Mon Sep 17 00:00:00 2001 From: Rob Zolkos Date: Thu, 20 Aug 2026 07:40:40 -0400 Subject: [PATCH 2/2] Handle explicit jq flags safely --- internal/cmd/jq_test.go | 45 +++++++++++++++---------- internal/cmd/local_config_trust.go | 6 ++-- internal/cmd/local_config_trust_test.go | 17 ++++++++++ internal/cmd/root.go | 18 ++++++---- 4 files changed, 59 insertions(+), 27 deletions(-) diff --git a/internal/cmd/jq_test.go b/internal/cmd/jq_test.go index 905d5dcf..af115729 100644 --- a/internal/cmd/jq_test.go +++ b/internal/cmd/jq_test.go @@ -18,23 +18,25 @@ func TestRootRegistersJQFlag(t *testing.T) { func TestValidateJQFlags(t *testing.T) { tests := []struct { - name string - args []string - filter string - ids bool - count bool - want string + name string + args []string + filter string + requested bool + ids bool + count bool + want string }{ - {name: "empty", args: []string{"auth", "status"}}, - {name: "valid", args: []string{"auth", "status"}, filter: ".data[].id"}, - {name: "invalid", args: []string{"auth", "status"}, filter: ".[invalid", want: "invalid --jq expression"}, - {name: "ids conflict", args: []string{"auth", "status"}, filter: ".data", ids: true, want: "cannot use --jq with --ids-only"}, - {name: "count conflict", args: []string{"auth", "status"}, filter: ".data", count: true, want: "cannot use --jq with --count"}, - {name: "root app", filter: ".", want: "--jq is not supported by the interactive app"}, - {name: "auth token", args: []string{"auth", "token"}, filter: ".", want: "--jq is not supported by the auth token command"}, - {name: "completion", args: []string{"completion"}, filter: ".", want: "--jq is not supported by the completion command"}, - {name: "skill display", args: []string{"skill"}, filter: ".", want: "--jq is not supported by the skill display command"}, - {name: "tui", args: []string{"tui"}, filter: ".", want: "--jq is not supported by the interactive app"}, + {name: "absent", args: []string{"auth", "status"}}, + {name: "empty", args: []string{"auth", "status"}, requested: true, want: "invalid --jq expression: expression cannot be empty"}, + {name: "valid", args: []string{"auth", "status"}, filter: ".data[].id", requested: true}, + {name: "invalid", args: []string{"auth", "status"}, filter: ".[invalid", requested: true, want: "invalid --jq expression"}, + {name: "ids conflict", args: []string{"auth", "status"}, filter: ".data", requested: true, ids: true, want: "cannot use --jq with --ids-only"}, + {name: "count conflict", args: []string{"auth", "status"}, filter: ".data", requested: true, count: true, want: "cannot use --jq with --count"}, + {name: "root app", filter: ".", requested: true, want: "--jq is not supported by the interactive app"}, + {name: "auth token", args: []string{"auth", "token"}, filter: ".", requested: true, want: "--jq is not supported by the auth token command"}, + {name: "completion", args: []string{"completion"}, filter: ".", requested: true, want: "--jq is not supported by the completion command"}, + {name: "skill display", args: []string{"skill"}, filter: ".", requested: true, want: "--jq is not supported by the skill display command"}, + {name: "tui", args: []string{"tui"}, filter: ".", requested: true, want: "--jq is not supported by the interactive app"}, } for _, tt := range tests { @@ -44,7 +46,7 @@ func TestValidateJQFlags(t *testing.T) { if err != nil { t.Fatal(err) } - err = validateJQFlags(cmd, tt.filter, tt.ids, tt.count) + err = validateJQFlags(cmd, tt.filter, tt.requested, tt.ids, tt.count) if tt.want == "" { if err != nil { t.Fatalf("unexpected error: %v", err) @@ -58,6 +60,15 @@ func TestValidateJQFlags(t *testing.T) { } } +func TestRootRejectsExplicitEmptyJQExpression(t *testing.T) { + root := newRootCmd() + root.SetArgs([]string{"auth", "status", "--jq="}) + err := root.Execute() + if err == nil || err.Error() != "invalid --jq expression: expression cannot be empty" { + t.Fatalf("unexpected error: %v", err) + } +} + func TestRootVersionSupportsRawOutputAndRejectsJQ(t *testing.T) { t.Run("version", func(t *testing.T) { root := newRootCmd() diff --git a/internal/cmd/local_config_trust.go b/internal/cmd/local_config_trust.go index 2ac16b70..f2a15a43 100644 --- a/internal/cmd/local_config_trust.go +++ b/internal/cmd/local_config_trust.go @@ -26,7 +26,7 @@ func ensureLocalConfigTrusted(cmd *cobra.Command) error { if local == nil || !commandUsesRuntimeConfig(cmd) { return nil } - if machineReadableOutput() || !stdinIsTerminal() || !stdoutIsTerminal() { + if machineReadableOutput(cmd) || !stdinIsTerminal() || !stdoutIsTerminal() { return untrustedLocalConfigError(local) } @@ -60,8 +60,8 @@ func commandUsesRuntimeConfig(cmd *cobra.Command) bool { } } -func machineReadableOutput() bool { - return jsonFlag || quietFlag || idsOnly || countFlag || markdownF || agentFlag +func machineReadableOutput(cmd *cobra.Command) bool { + return jsonFlag || quietFlag || idsOnly || countFlag || markdownF || agentFlag || cmd.Flags().Changed("jq") } func promptForLocalConfigTrust(cmd *cobra.Command, local *config.LocalConfig) (localConfigTrustChoice, error) { diff --git a/internal/cmd/local_config_trust_test.go b/internal/cmd/local_config_trust_test.go index 28fa7c12..0a8f3b87 100644 --- a/internal/cmd/local_config_trust_test.go +++ b/internal/cmd/local_config_trust_test.go @@ -34,6 +34,20 @@ func TestUntrustedLocalConfigFailsBeforeNetworkRequest(t *testing.T) { } } +func TestJQIsMachineReadableForLocalConfigTrust(t *testing.T) { + root := newRootCmd() + command, _, err := root.Find([]string{"boxes"}) + if err != nil { + t.Fatal(err) + } + if err := command.ParseFlags([]string{"--jq", ".data"}); err != nil { + t.Fatal(err) + } + if !machineReadableOutput(command) { + t.Fatal("--jq output was treated as interactive") + } +} + func TestTrustLocalAllowsRequestsAndChangesRequireTrustAgain(t *testing.T) { requests := 0 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -161,13 +175,16 @@ func runLocalTrustCLI(t *testing.T, args ...string) error { func resetRootFlagsForTrustTest(t *testing.T) { t.Helper() previousJSON := jsonFlag + previousJQ := jqFlag previousAccount := accountFlag previousBaseURL := baseURL jsonFlag = false + jqFlag = "" accountFlag = "" baseURL = "" t.Cleanup(func() { jsonFlag = previousJSON + jqFlag = previousJQ accountFlag = previousAccount baseURL = previousBaseURL }) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 5d0270e5..6727742d 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -56,7 +56,8 @@ func newRootCmd() *cobra.Command { SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - format := output.FormatFromFlags(jsonFlag || jqFlag != "", quietFlag, idsOnly, countFlag, markdownF, styledFlag, agentFlag) + jqRequested := cmd.Flags().Changed("jq") + format := output.FormatFromFlags(jsonFlag || jqRequested, quietFlag, idsOnly, countFlag, markdownF, styledFlag, agentFlag) writer = output.New(output.Options{ Format: format, Stdout: cmd.OutOrStdout(), @@ -64,12 +65,12 @@ func newRootCmd() *cobra.Command { JQFilter: jqFlag, }) if versionFlag { - if jqFlag != "" { + if jqRequested { return output.ErrJQNotSupported("the version command") } return nil } - if err := validateJQFlags(cmd, jqFlag, idsOnly, countFlag); err != nil { + if err := validateJQFlags(cmd, jqFlag, jqRequested, idsOnly, countFlag); err != nil { return err } @@ -219,12 +220,15 @@ func Execute() { } } -func validateJQFlags(cmd *cobra.Command, filter string, ids, count bool) error { - if err := output.ValidateJQFilter(filter); err != nil { - return err +func validateJQFlags(cmd *cobra.Command, filter string, requested, ids, count bool) error { + if !requested { + return nil } if filter == "" { - return nil + return output.ErrJQValidation(errors.New("expression cannot be empty")) + } + if err := output.ValidateJQFilter(filter); err != nil { + return err } if ids { return output.ErrJQConflict("--ids-only")