Skip to content

Commit cf82131

Browse files
committed
Add color output and spinner
- tidwall/pretty: colorized JSON on TTY (blue keys, green strings, yellow numbers, magenta bools, red null). Respects NO_COLOR env var. - briandowns/spinner: braille dots spinner on stderr during API calls, only when stderr is a TTY. Same library as gh CLI. - Fix CI: go test ./pkg/... in unit job (avoids e2e go build)
1 parent 58a8d99 commit cf82131

File tree

10 files changed

+111
-1
lines changed

10 files changed

+111
-1
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
- uses: actions/setup-go@v5
1515
with:
1616
go-version-file: go.mod
17-
- run: go test ./...
17+
- run: go test ./pkg/...
1818

1919
e2e:
2020
runs-on: ubuntu-latest

go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,13 @@ require (
1010
)
1111

1212
require (
13+
github.com/briandowns/spinner v1.23.2 // indirect
14+
github.com/fatih/color v1.7.0 // indirect
1315
github.com/inconshreveable/mousetrap v1.1.0 // indirect
1416
github.com/itchyny/timefmt-go v0.1.7 // indirect
17+
github.com/mattn/go-colorable v0.1.2 // indirect
18+
github.com/mattn/go-isatty v0.0.20 // indirect
1519
github.com/spf13/pflag v1.0.9 // indirect
20+
github.com/tidwall/pretty v1.2.1 // indirect
1621
golang.org/x/sys v0.42.0 // indirect
1722
)

go.sum

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,31 @@
11
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
22
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3+
github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=
4+
github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=
35
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
6+
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
7+
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
48
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
59
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
610
github.com/itchyny/gojq v0.12.18 h1:gFGHyt/MLbG9n6dqnvlliiya2TaMMh6FFaR2b1H6Drc=
711
github.com/itchyny/gojq v0.12.18/go.mod h1:4hPoZ/3lN9fDL1D+aK7DY1f39XZpY9+1Xpjz8atrEkg=
812
github.com/itchyny/timefmt-go v0.1.7 h1:xyftit9Tbw+Dc/huSSPJaEmX1TVL8lw5vxjJLK4GMMA=
913
github.com/itchyny/timefmt-go v0.1.7/go.mod h1:5E46Q+zj7vbTgWY8o5YkMeYb4I6GeWLFnetPy5oBrAI=
14+
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
15+
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
16+
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
17+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
18+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
1019
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
1120
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
1221
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
1322
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
1423
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
24+
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
25+
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
1526
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
27+
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
28+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
1629
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
1730
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
1831
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=

pkg/cmd/account.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ func runAccount(cmd *cobra.Command, args []string) error {
2323
return err
2424
}
2525

26+
sp := newSpinner("Fetching account...")
27+
sp.Start()
2628
client := api.New(apiKey)
2729
result, err := client.Account()
30+
sp.Stop()
2831
if err != nil {
2932
return err
3033
}

pkg/cmd/archive.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ func runArchive(cmd *cobra.Command, args []string) error {
2323
return err
2424
}
2525

26+
sp := newSpinner("Fetching archive...")
27+
sp.Start()
2628
client := api.New(apiKey)
2729
result, err := client.Archive(args[0])
30+
sp.Stop()
2831
if err != nil {
2932
return err
3033
}

pkg/cmd/locations.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ func runLocations(cmd *cobra.Command, args []string) error {
2929
}
3030

3131
paramsMap := params.ParamsToMap(parsed)
32+
sp := newSpinner("Fetching locations...")
33+
sp.Start()
3234
client := api.New("")
3335
result, err := client.Locations(paramsMap)
36+
sp.Stop()
3437
if err != nil {
3538
return err
3639
}

pkg/cmd/root.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
clierrors "github.com/serpapi/serpapi-cli/pkg/errors"
1111
"github.com/serpapi/serpapi-cli/pkg/jq"
1212
"github.com/serpapi/serpapi-cli/pkg/output"
13+
"github.com/serpapi/serpapi-cli/pkg/spinner"
1314
"github.com/serpapi/serpapi-cli/pkg/version"
1415
)
1516

@@ -89,3 +90,8 @@ func handleOutput(result any) error {
8990
}
9091
return nil
9192
}
93+
94+
// newSpinner creates a spinner with the given label.
95+
func newSpinner(label string) *spinner.Spinner {
96+
return spinner.New(label)
97+
}

pkg/cmd/search.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"fmt"
45
"net/url"
56
"sort"
67

@@ -46,8 +47,11 @@ func runSearch(cmd *cobra.Command, args []string) error {
4647
hasMaxPages := cmd.Flags().Changed("max-pages")
4748

4849
if !allPagesFlag && !hasMaxPages {
50+
sp := newSpinner("Searching...")
51+
sp.Start()
4952
client := api.New(apiKey)
5053
result, err := client.Search(paramsMap)
54+
sp.Stop()
5155
if err != nil {
5256
return err
5357
}
@@ -67,7 +71,10 @@ func runSearch(cmd *cobra.Command, args []string) error {
6771
seen[canonicalParamsKey(currentParams)] = true
6872

6973
for {
74+
sp := newSpinner(fmt.Sprintf("Fetching page %d...", pagesFetched+1))
75+
sp.Start()
7076
result, err := client.Search(currentParams)
77+
sp.Stop()
7178
if err != nil {
7279
return err
7380
}

pkg/output/output.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,40 @@ import (
55
"fmt"
66
"io"
77
"os"
8+
9+
"github.com/tidwall/pretty"
10+
"golang.org/x/term"
811
)
912

13+
// darkStyle matches stripe-cli's darkTerminalStyle exactly.
14+
var darkStyle = &pretty.Style{
15+
Key: [2]string{"\x1B[34m", "\x1B[0m"}, // blue
16+
String: [2]string{"\x1B[32m", "\x1B[0m"}, // green
17+
Number: [2]string{"\x1B[33m", "\x1B[0m"}, // yellow
18+
True: [2]string{"\x1B[35m", "\x1B[0m"}, // magenta
19+
False: [2]string{"\x1B[35m", "\x1B[0m"}, // magenta
20+
Null: [2]string{"\x1B[31m", "\x1B[0m"}, // red
21+
}
22+
23+
// shouldColor returns true when stdout is a TTY and NO_COLOR is not set.
24+
func shouldColor() bool {
25+
if os.Getenv("NO_COLOR") != "" {
26+
return false
27+
}
28+
return term.IsTerminal(int(os.Stdout.Fd()))
29+
}
30+
1031
// PrintJSON prints a value to stdout as pretty-printed JSON.
32+
// Colorized when stdout is a TTY and NO_COLOR is not set.
1133
func PrintJSON(v any) error {
1234
data, err := json.MarshalIndent(v, "", " ")
1335
if err != nil {
1436
return err
1537
}
38+
if shouldColor() {
39+
_, err = fmt.Fprint(os.Stdout, string(pretty.Color(data, darkStyle)))
40+
return err
41+
}
1642
_, err = fmt.Fprintln(os.Stdout, string(data))
1743
return err
1844
}
@@ -56,3 +82,4 @@ func formatNumber(f float64) string {
5682
}
5783
return fmt.Sprintf("%g", f)
5884
}
85+

pkg/spinner/spinner.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// Package spinner provides a TTY-aware activity spinner for long-running operations.
2+
package spinner
3+
4+
import (
5+
"os"
6+
"time"
7+
8+
"github.com/briandowns/spinner"
9+
"golang.org/x/term"
10+
)
11+
12+
// Spinner wraps briandowns/spinner with TTY awareness.
13+
type Spinner struct {
14+
s *spinner.Spinner
15+
}
16+
17+
// New creates a spinner that writes to stderr.
18+
// Returns a no-op spinner when stderr is not a TTY (pipes, CI, redirects).
19+
func New(label string) *Spinner {
20+
if !term.IsTerminal(int(os.Stderr.Fd())) {
21+
return &Spinner{}
22+
}
23+
s := spinner.New(spinner.CharSets[14], 80*time.Millisecond,
24+
spinner.WithWriter(os.Stderr),
25+
spinner.WithColor("cyan"),
26+
)
27+
s.Suffix = " " + label
28+
return &Spinner{s: s}
29+
}
30+
31+
// Start begins the spinner animation.
32+
func (sp *Spinner) Start() {
33+
if sp.s != nil {
34+
sp.s.Start()
35+
}
36+
}
37+
38+
// Stop halts the spinner and clears it from the terminal.
39+
func (sp *Spinner) Stop() {
40+
if sp.s != nil {
41+
sp.s.Stop()
42+
}
43+
}

0 commit comments

Comments
 (0)