Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .surface
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id|all>` to select a linked mail account.
Structured data commands support `--json` for full output and `--jq '<expression>'` 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 <id|all>` to select a linked
mail account.

```bash
hey boxes --jq '.data[] | {id, name}'
hey boxes --quiet --jq '.[].id'
```

### Email

Expand Down
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions internal/cmd/jq_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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
requested bool
ids bool
count bool
want string
}{
{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 {
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.requested, 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 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()
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)
}
})
}
6 changes: 3 additions & 3 deletions internal/cmd/local_config_trust.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions internal/cmd/local_config_trust_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
})
Expand Down
66 changes: 58 additions & 8 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ var (
styledFlag bool
agentFlag bool
statsFlag bool
jqFlag string
versionFlag bool
verboseFlag int
baseURL string
accountFlag string
Expand All @@ -54,12 +56,23 @@ 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)
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(),
Stderr: cmd.ErrOrStderr(),
Format: format,
Stdout: cmd.OutOrStdout(),
Stderr: cmd.ErrOrStderr(),
JQFilter: jqFlag,
})
if versionFlag {
if jqRequested {
return output.ErrJQNotSupported("the version command")
}
return nil
}
if err := validateJQFlags(cmd, jqFlag, jqRequested, idsOnly, countFlag); err != nil {
return err
}

var err error
cfg, err = config.Load()
Expand Down Expand Up @@ -101,6 +114,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()
}
Expand All @@ -126,9 +143,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()))
Expand Down Expand Up @@ -191,7 +207,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:") {
Expand All @@ -203,6 +220,39 @@ func Execute() {
}
}

func validateJQFlags(cmd *cobra.Command, filter string, requested, ids, count bool) error {
if !requested {
return nil
}
if filter == "" {
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")
}
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")
Expand Down
Loading