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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,14 @@ fizzy comment create --card 42 --body "Looks good!" # Add comment
### Output Formats

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

`--jq` is for machine-readable JSON output. It implies `--json` and cannot be combined with `--styled`, `--markdown`, `--ids-only`, or `--count`.

### JSON Envelope

Every command returns structured JSON:
Expand Down
152 changes: 152 additions & 0 deletions SURFACE.txt

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/x/term v0.2.2
github.com/itchyny/gojq v0.12.18
github.com/mattn/go-isatty v0.0.20
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2
Expand All @@ -27,21 +28,24 @@ require (
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/danieljoos/wincred v1.2.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zalando/go-keyring v0.2.6 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/text v0.23.0 // indirect
)
17 changes: 12 additions & 5 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
Expand All @@ -59,14 +63,18 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
Expand All @@ -77,7 +85,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
Expand All @@ -103,8 +110,8 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
Expand Down
17 changes: 17 additions & 0 deletions internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,20 @@ func agentHelp(cmd *cobra.Command, _ []string) {
data, _ := json.MarshalIndent(info, "", " ")
fmt.Fprintln(outWriter, string(data))
}

// installAgentHelp sets the custom help function when --agent is active.
func installAgentHelp() {
rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
if cfgAgent {
agentHelp(cmd, args)
return
}
// Banner on root help only
if cmd == rootCmd {
printBanner()
}
// Fall back to Cobra's default help
cmd.Root().SetHelpFunc(nil)
_ = cmd.Help()
})
}
4 changes: 4 additions & 0 deletions internal/commands/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"

"github.com/basecamp/cli/output"
"github.com/basecamp/fizzy-cli/internal/errors"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -42,6 +43,9 @@ PowerShell:
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs),
RunE: func(cmd *cobra.Command, args []string) error {
if cfgJQ != "" {
return errors.ErrJQNotSupported("completion script generation")
}
var err error
switch args[0] {
case "bash":
Expand Down
51 changes: 51 additions & 0 deletions internal/commands/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,56 @@ func TestFormatJSONEnvelope(t *testing.T) {
}
}

func TestVersionJSONOutput(t *testing.T) {
mock := NewMockClient()
SetTestModeWithSDK(mock)
SetTestConfig("token", "account", "https://api.example.com")
defer resetTest()

raw, err := runCobraWithArgs("version", "--json")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var envelope map[string]any
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
t.Fatalf("expected JSON object, got parse error: %v\noutput: %s", err, raw)
}
if envelope["ok"] != true {
t.Errorf("expected ok=true, got %v", envelope["ok"])
}
data, ok := envelope["data"].(map[string]any)
if !ok {
t.Fatalf("expected data object, got %T", envelope["data"])
}
if data["version"] != rootCmd.Version {
t.Errorf("expected version %q, got %v", rootCmd.Version, data["version"])
}
}

func TestVersionQuietOutput(t *testing.T) {
mock := NewMockClient()
SetTestModeWithSDK(mock)
SetTestConfig("token", "account", "https://api.example.com")
defer resetTest()

raw, err := runCobraWithArgs("version", "--quiet")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
t.Fatalf("expected JSON object, got parse error: %v\noutput: %s", err, raw)
}
if _, hasOK := data["ok"]; hasOK {
t.Error("quiet output should not include envelope")
}
if data["version"] != rootCmd.Version {
t.Errorf("expected version %q, got %v", rootCmd.Version, data["version"])
}
}

func TestPrettyFlagRemoved(t *testing.T) {
flag := rootCmd.PersistentFlags().Lookup("pretty")
if flag != nil {
Expand Down Expand Up @@ -281,6 +331,7 @@ func runCobraWithArgs(args ...string) (string, error) {
cfgAgent = false
cfgStyled = false
cfgMarkdown = false
cfgJQ = ""
testBuf.Reset()
lastRawOutput = ""
out = output.New(output.Options{Format: output.FormatJSON, Writer: &testBuf})
Expand Down
14 changes: 13 additions & 1 deletion internal/commands/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func TestRenderRootHelp(t *testing.T) {
renderHelp(rootCmd, &buf)
out := buf.String()

for _, want := range []string{"CORE COMMANDS", "FLAGS", "--version", "GLOBAL OUTPUT FLAGS", "LEARN MORE"} {
for _, want := range []string{"CORE COMMANDS", "FLAGS", "--version", "GLOBAL OUTPUT FLAGS", "LEARN MORE", "implies --json"} {
if !strings.Contains(out, want) {
t.Fatalf("expected root help to contain %q, got:\n%s", want, out)
}
Expand Down Expand Up @@ -89,3 +89,15 @@ func TestRenderCommandsHelpMentionsJSONCatalog(t *testing.T) {
t.Fatalf("expected commands help examples to include --json, got:\n%s", out)
}
}

func TestRenderSubcommandHelpDoesNotRepeatJQFlag(t *testing.T) {
configureCLIUX()

var buf bytes.Buffer
renderHelp(boardListCmd, &buf)
out := buf.String()

if strings.Contains(out, "--jq") {
t.Fatalf("expected subcommand help to omit --jq, got:\n%s", out)
}
}
92 changes: 92 additions & 0 deletions internal/commands/jq.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package commands

import (
"encoding/json"
"fmt"
"io"
"os"

"github.com/basecamp/fizzy-cli/internal/errors"
"github.com/itchyny/gojq"
)

// jqWriter wraps an io.Writer and applies a compiled jq filter to JSON output.
// Non-JSON writes pass through unchanged.
type jqWriter struct {
dest io.Writer
code *gojq.Code
}

// newJQWriter parses and compiles the jq expression and returns a filtering writer.
// Delegates to compileJQ for compilation so options are maintained in one place.
func newJQWriter(dest io.Writer, filter string) (*jqWriter, error) {
code, err := compileJQ(filter)
if err != nil {
return nil, err
}
return &jqWriter{dest: dest, code: code}, nil
}

// newJQWriterWithCode creates a jqWriter using a pre-compiled *gojq.Code.
// Used when the expression has already been validated and compiled (e.g. in PersistentPreRunE).
func newJQWriterWithCode(dest io.Writer, code *gojq.Code) *jqWriter {
return &jqWriter{dest: dest, code: code}
}

// compileJQ parses and compiles a jq expression, returning the compiled code.
func compileJQ(filter string) (*gojq.Code, error) {
query, err := gojq.Parse(filter)
if err != nil {
return nil, errors.ErrJQValidation(err)
}
code, err := gojq.Compile(query, gojq.WithEnvironLoader(os.Environ))
if err != nil {
return nil, errors.ErrJQValidation(err)
}
return code, nil
}

// Write intercepts JSON output, applies the jq filter, and writes filtered results.
// String results print as plain text; everything else prints as compact single-line JSON.
// Error envelopes (ok: false) pass through unfiltered so error messages are never hidden.
func (w *jqWriter) Write(p []byte) (int, error) {
var input any
if err := json.Unmarshal(p, &input); err != nil {
// Not JSON — pass through unchanged.
return w.dest.Write(p)
}

// Pass through error envelopes unfiltered so jq doesn't hide error messages.
if m, ok := input.(map[string]any); ok {
if okVal, exists := m["ok"]; exists {
if okBool, isBool := okVal.(bool); isBool && !okBool {
return w.dest.Write(p)
}
}
}

iter := w.code.Run(input)
for {
v, ok := iter.Next()
if !ok {
break
}
if err, isErr := v.(error); isErr {
return 0, errors.ErrJQRuntime(err)
}
if s, isStr := v.(string); isStr {
if _, err := fmt.Fprintln(w.dest, s); err != nil {
return 0, err
}
} else {
raw, err := json.Marshal(v)
if err != nil {
return 0, errors.ErrJQRuntime(fmt.Errorf("result not serializable: %w", err))
}
if _, err := fmt.Fprintln(w.dest, string(raw)); err != nil {
return 0, err
}
}
}
return len(p), nil
}
Loading
Loading