Skip to content

Commit 094e6b0

Browse files
authored
Merge pull request #101 from basecamp/rz/built-in-jq
Add built-in --jq flag via gojq
2 parents b18ab35 + bbf6372 commit 094e6b0

18 files changed

Lines changed: 1396 additions & 85 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,14 @@ fizzy comment create --card 42 --body "Looks good!" # Add comment
8787
### Output Formats
8888

8989
```bash
90-
fizzy board list # JSON output
91-
fizzy board list | jq '.data' # Pipe through jq for raw data
90+
fizzy board list # JSON output
91+
fizzy board list --jq '.data[0].name' # Filter the JSON envelope (built-in, no external jq required)
92+
fizzy board list --quiet --jq '.[0].name' # Filter raw data without the envelope
93+
fizzy board list --jq '[.data[] | {id, name}]' # Extract specific fields
9294
```
9395

96+
`--jq` is for machine-readable JSON output. It implies `--json` and cannot be combined with `--styled`, `--markdown`, `--ids-only`, or `--count`.
97+
9498
### JSON Envelope
9599

96100
Every command returns structured JSON:

SURFACE.txt

Lines changed: 152 additions & 0 deletions
Large diffs are not rendered by default.

go.mod

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/charmbracelet/huh v1.0.0
99
github.com/charmbracelet/lipgloss v1.1.0
1010
github.com/charmbracelet/x/term v0.2.2
11+
github.com/itchyny/gojq v0.12.18
1112
github.com/mattn/go-isatty v0.0.20
1213
github.com/muesli/termenv v0.16.0
1314
github.com/spf13/cobra v1.10.2
@@ -27,21 +28,24 @@ require (
2728
github.com/charmbracelet/x/ansi v0.9.3 // indirect
2829
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
2930
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
31+
github.com/clipperhouse/stringish v0.1.1 // indirect
32+
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
3033
github.com/danieljoos/wincred v1.2.2 // indirect
3134
github.com/dustin/go-humanize v1.0.1 // indirect
3235
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
3336
github.com/godbus/dbus/v5 v5.1.0 // indirect
3437
github.com/inconshreveable/mousetrap v1.1.0 // indirect
38+
github.com/itchyny/timefmt-go v0.1.7 // indirect
3539
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
3640
github.com/mattn/go-localereader v0.0.1 // indirect
37-
github.com/mattn/go-runewidth v0.0.16 // indirect
41+
github.com/mattn/go-runewidth v0.0.19 // indirect
3842
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
3943
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
4044
github.com/muesli/cancelreader v0.2.2 // indirect
4145
github.com/rivo/uniseg v0.4.7 // indirect
4246
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
4347
github.com/zalando/go-keyring v0.2.6 // indirect
4448
golang.org/x/sync v0.15.0 // indirect
45-
golang.org/x/sys v0.36.0 // indirect
49+
golang.org/x/sys v0.38.0 // indirect
4650
golang.org/x/text v0.23.0 // indirect
4751
)

go.sum

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
4242
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
4343
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
4444
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
45+
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
46+
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
47+
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
48+
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
4549
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
4650
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
4751
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
@@ -59,14 +63,18 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
5963
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
6064
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
6165
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
66+
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
67+
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
68+
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
69+
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
6270
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
6371
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
6472
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
6573
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
6674
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
6775
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
68-
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
69-
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
76+
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
77+
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
7078
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
7179
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
7280
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
@@ -77,7 +85,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
7785
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
7886
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
7987
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
80-
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
8188
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
8289
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
8390
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -103,8 +110,8 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
103110
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
104111
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
105112
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
106-
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
107-
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
113+
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
114+
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
108115
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
109116
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
110117
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

internal/commands/commands.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,20 @@ func agentHelp(cmd *cobra.Command, _ []string) {
110110
data, _ := json.MarshalIndent(info, "", " ")
111111
fmt.Fprintln(outWriter, string(data))
112112
}
113+
114+
// installAgentHelp sets the custom help function when --agent is active.
115+
func installAgentHelp() {
116+
rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
117+
if cfgAgent {
118+
agentHelp(cmd, args)
119+
return
120+
}
121+
// Banner on root help only
122+
if cmd == rootCmd {
123+
printBanner()
124+
}
125+
// Fall back to Cobra's default help
126+
cmd.Root().SetHelpFunc(nil)
127+
_ = cmd.Help()
128+
})
129+
}

internal/commands/completion.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66

77
"github.com/basecamp/cli/output"
8+
"github.com/basecamp/fizzy-cli/internal/errors"
89
"github.com/spf13/cobra"
910
)
1011

@@ -42,6 +43,9 @@ PowerShell:
4243
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
4344
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
4445
RunE: func(cmd *cobra.Command, args []string) error {
46+
if cfgJQ != "" {
47+
return errors.ErrJQNotSupported("completion script generation")
48+
}
4549
var err error
4650
switch args[0] {
4751
case "bash":

internal/commands/format_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,56 @@ func TestFormatJSONEnvelope(t *testing.T) {
254254
}
255255
}
256256

257+
func TestVersionJSONOutput(t *testing.T) {
258+
mock := NewMockClient()
259+
SetTestModeWithSDK(mock)
260+
SetTestConfig("token", "account", "https://api.example.com")
261+
defer resetTest()
262+
263+
raw, err := runCobraWithArgs("version", "--json")
264+
if err != nil {
265+
t.Fatalf("unexpected error: %v", err)
266+
}
267+
268+
var envelope map[string]any
269+
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
270+
t.Fatalf("expected JSON object, got parse error: %v\noutput: %s", err, raw)
271+
}
272+
if envelope["ok"] != true {
273+
t.Errorf("expected ok=true, got %v", envelope["ok"])
274+
}
275+
data, ok := envelope["data"].(map[string]any)
276+
if !ok {
277+
t.Fatalf("expected data object, got %T", envelope["data"])
278+
}
279+
if data["version"] != rootCmd.Version {
280+
t.Errorf("expected version %q, got %v", rootCmd.Version, data["version"])
281+
}
282+
}
283+
284+
func TestVersionQuietOutput(t *testing.T) {
285+
mock := NewMockClient()
286+
SetTestModeWithSDK(mock)
287+
SetTestConfig("token", "account", "https://api.example.com")
288+
defer resetTest()
289+
290+
raw, err := runCobraWithArgs("version", "--quiet")
291+
if err != nil {
292+
t.Fatalf("unexpected error: %v", err)
293+
}
294+
295+
var data map[string]any
296+
if err := json.Unmarshal([]byte(raw), &data); err != nil {
297+
t.Fatalf("expected JSON object, got parse error: %v\noutput: %s", err, raw)
298+
}
299+
if _, hasOK := data["ok"]; hasOK {
300+
t.Error("quiet output should not include envelope")
301+
}
302+
if data["version"] != rootCmd.Version {
303+
t.Errorf("expected version %q, got %v", rootCmd.Version, data["version"])
304+
}
305+
}
306+
257307
func TestPrettyFlagRemoved(t *testing.T) {
258308
flag := rootCmd.PersistentFlags().Lookup("pretty")
259309
if flag != nil {
@@ -281,6 +331,7 @@ func runCobraWithArgs(args ...string) (string, error) {
281331
cfgAgent = false
282332
cfgStyled = false
283333
cfgMarkdown = false
334+
cfgJQ = ""
284335
testBuf.Reset()
285336
lastRawOutput = ""
286337
out = output.New(output.Options{Format: output.FormatJSON, Writer: &testBuf})

internal/commands/help_test.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func TestRenderRootHelp(t *testing.T) {
1515
renderHelp(rootCmd, &buf)
1616
out := buf.String()
1717

18-
for _, want := range []string{"CORE COMMANDS", "FLAGS", "--version", "GLOBAL OUTPUT FLAGS", "LEARN MORE"} {
18+
for _, want := range []string{"CORE COMMANDS", "FLAGS", "--version", "GLOBAL OUTPUT FLAGS", "LEARN MORE", "implies --json"} {
1919
if !strings.Contains(out, want) {
2020
t.Fatalf("expected root help to contain %q, got:\n%s", want, out)
2121
}
@@ -89,3 +89,15 @@ func TestRenderCommandsHelpMentionsJSONCatalog(t *testing.T) {
8989
t.Fatalf("expected commands help examples to include --json, got:\n%s", out)
9090
}
9191
}
92+
93+
func TestRenderSubcommandHelpDoesNotRepeatJQFlag(t *testing.T) {
94+
configureCLIUX()
95+
96+
var buf bytes.Buffer
97+
renderHelp(boardListCmd, &buf)
98+
out := buf.String()
99+
100+
if strings.Contains(out, "--jq") {
101+
t.Fatalf("expected subcommand help to omit --jq, got:\n%s", out)
102+
}
103+
}

internal/commands/jq.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package commands
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"os"
8+
9+
"github.com/basecamp/fizzy-cli/internal/errors"
10+
"github.com/itchyny/gojq"
11+
)
12+
13+
// jqWriter wraps an io.Writer and applies a compiled jq filter to JSON output.
14+
// Non-JSON writes pass through unchanged.
15+
type jqWriter struct {
16+
dest io.Writer
17+
code *gojq.Code
18+
}
19+
20+
// newJQWriter parses and compiles the jq expression and returns a filtering writer.
21+
// Delegates to compileJQ for compilation so options are maintained in one place.
22+
func newJQWriter(dest io.Writer, filter string) (*jqWriter, error) {
23+
code, err := compileJQ(filter)
24+
if err != nil {
25+
return nil, err
26+
}
27+
return &jqWriter{dest: dest, code: code}, nil
28+
}
29+
30+
// newJQWriterWithCode creates a jqWriter using a pre-compiled *gojq.Code.
31+
// Used when the expression has already been validated and compiled (e.g. in PersistentPreRunE).
32+
func newJQWriterWithCode(dest io.Writer, code *gojq.Code) *jqWriter {
33+
return &jqWriter{dest: dest, code: code}
34+
}
35+
36+
// compileJQ parses and compiles a jq expression, returning the compiled code.
37+
func compileJQ(filter string) (*gojq.Code, error) {
38+
query, err := gojq.Parse(filter)
39+
if err != nil {
40+
return nil, errors.ErrJQValidation(err)
41+
}
42+
code, err := gojq.Compile(query, gojq.WithEnvironLoader(os.Environ))
43+
if err != nil {
44+
return nil, errors.ErrJQValidation(err)
45+
}
46+
return code, nil
47+
}
48+
49+
// Write intercepts JSON output, applies the jq filter, and writes filtered results.
50+
// String results print as plain text; everything else prints as compact single-line JSON.
51+
// Error envelopes (ok: false) pass through unfiltered so error messages are never hidden.
52+
func (w *jqWriter) Write(p []byte) (int, error) {
53+
var input any
54+
if err := json.Unmarshal(p, &input); err != nil {
55+
// Not JSON — pass through unchanged.
56+
return w.dest.Write(p)
57+
}
58+
59+
// Pass through error envelopes unfiltered so jq doesn't hide error messages.
60+
if m, ok := input.(map[string]any); ok {
61+
if okVal, exists := m["ok"]; exists {
62+
if okBool, isBool := okVal.(bool); isBool && !okBool {
63+
return w.dest.Write(p)
64+
}
65+
}
66+
}
67+
68+
iter := w.code.Run(input)
69+
for {
70+
v, ok := iter.Next()
71+
if !ok {
72+
break
73+
}
74+
if err, isErr := v.(error); isErr {
75+
return 0, errors.ErrJQRuntime(err)
76+
}
77+
if s, isStr := v.(string); isStr {
78+
if _, err := fmt.Fprintln(w.dest, s); err != nil {
79+
return 0, err
80+
}
81+
} else {
82+
raw, err := json.Marshal(v)
83+
if err != nil {
84+
return 0, errors.ErrJQRuntime(fmt.Errorf("result not serializable: %w", err))
85+
}
86+
if _, err := fmt.Fprintln(w.dest, string(raw)); err != nil {
87+
return 0, err
88+
}
89+
}
90+
}
91+
return len(p), nil
92+
}

0 commit comments

Comments
 (0)