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
309 changes: 309 additions & 0 deletions .surface

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/charmbracelet/x/term v0.2.2
github.com/fsnotify/fsnotify v1.9.0
github.com/gofrs/flock v0.13.0
github.com/itchyny/gojq v0.12.18
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
Expand Down Expand Up @@ -52,6 +53,7 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/itchyny/timefmt-go v0.1.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
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/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
Expand Down
38 changes: 21 additions & 17 deletions internal/appctx/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,14 @@ type App struct {
// GlobalFlags holds values for global CLI flags.
type GlobalFlags struct {
// Output format flags
JSON bool
Quiet bool
MD bool // Literal Markdown syntax output
Styled bool // Force ANSI styled output (even when piped)
IDsOnly bool
Count bool
Agent bool
JSON bool
Quiet bool
MD bool // Literal Markdown syntax output
Styled bool // Force ANSI styled output (even when piped)
IDsOnly bool
Count bool
Agent bool
JQFilter string // Built-in jq filter expression (via gojq)

// Context flags
Project string
Expand Down Expand Up @@ -149,8 +150,9 @@ func (a *App) ApplyFlags() {
if a.Flags.Agent {
// Agent mode = quiet JSON (data only, no envelope)
a.Output = output.New(output.Options{
Format: output.FormatQuiet,
Writer: os.Stdout,
Format: output.FormatQuiet,
Writer: os.Stdout,
JQFilter: a.Flags.JQFilter,
})
} else if a.Flags.IDsOnly {
a.Output = output.New(output.Options{
Expand All @@ -164,13 +166,15 @@ func (a *App) ApplyFlags() {
})
} else if a.Flags.Quiet {
a.Output = output.New(output.Options{
Format: output.FormatQuiet,
Writer: os.Stdout,
Format: output.FormatQuiet,
Writer: os.Stdout,
JQFilter: a.Flags.JQFilter,
})
} else if a.Flags.JSON {
} else if a.Flags.JSON || a.Flags.JQFilter != "" {
a.Output = output.New(output.Options{
Format: output.FormatJSON,
Writer: os.Stdout,
Format: output.FormatJSON,
Writer: os.Stdout,
JQFilter: a.Flags.JQFilter,
})
} else if a.Flags.Styled {
// Force ANSI styled output (even when piped)
Expand Down Expand Up @@ -299,7 +303,7 @@ func (a *App) shouldPrintStatsToStderr() bool {
// Use this to suppress human-friendly notices (like truncation warnings) in machine output.
func (a *App) IsMachineOutput() bool {
// Flag-driven machine output modes
if a.Flags.Agent || a.Flags.Quiet || a.Flags.IDsOnly || a.Flags.Count || a.Flags.JSON {
if a.Flags.Agent || a.Flags.Quiet || a.Flags.IDsOnly || a.Flags.Count || a.Flags.JSON || a.Flags.JQFilter != "" {
return true
}
// Config-driven machine output formats
Expand Down Expand Up @@ -327,7 +331,7 @@ func (a *App) printStatsToStderr(stats *observability.SessionMetrics) {
// IsInteractive returns true if the terminal supports interactive TUI.
func (a *App) IsInteractive() bool {
// Not interactive if any non-interactive output mode is set
if a.Flags.Agent || a.Flags.JSON || a.Flags.Quiet || a.Flags.IDsOnly || a.Flags.Count {
if a.Flags.Agent || a.Flags.JSON || a.Flags.Quiet || a.Flags.IDsOnly || a.Flags.Count || a.Flags.JQFilter != "" {
return false
}

Expand Down Expand Up @@ -408,7 +412,7 @@ func (a *App) Resolve() *resolve.Resolver {
Todolist: a.Flags.Todolist,
// Machine output flags - disable interactive prompts
Agent: a.Flags.Agent,
JSON: a.Flags.JSON,
JSON: a.Flags.JSON || a.Flags.JQFilter != "",
Quiet: a.Flags.Quiet,
IDsOnly: a.Flags.IDsOnly,
Count: a.Flags.Count,
Expand Down
1 change: 1 addition & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ func renderRootHelp(w io.Writer, cmd *cobra.Command) {
}
flags := []flagEntry{
{"-j", "--json", "Output as JSON"},
{"", "--jq", "Filter JSON with jq expression"},
{"-m", "--md", "Output as Markdown"},
{"-q", "--quiet", "Quiet output"},
{"-p", "--project", "Project ID or name"},
Expand Down
14 changes: 14 additions & 0 deletions internal/cli/help_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,20 @@ func TestLeafCommandHelpShowsArguments(t *testing.T) {
assert.Contains(t, out, "<id|url>")
}

func TestRootHelpContainsJQFlag(t *testing.T) {
isolateHelpTest(t)

var buf bytes.Buffer
cmd := NewRootCmd()
cmd.SetOut(&buf)
cmd.SetArgs([]string{"--help"})
_ = cmd.Execute()

out := buf.String()
assert.Contains(t, out, "--jq")
assert.Contains(t, out, "Filter JSON with jq expression")
}

func TestGroupCommandHelpOmitsArguments(t *testing.T) {
isolateHelpTest(t)

Expand Down
58 changes: 50 additions & 8 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"sort"
"strings"

"github.com/itchyny/gojq"
"github.com/spf13/cobra"
"github.com/spf13/pflag"

Expand Down Expand Up @@ -130,6 +131,24 @@ func NewRootCmd() *cobra.Command {
app.Flags = flags
app.ApplyFlags()

// Early jq validation: parse + compile before RunE so invalid
// expressions are rejected with no side effects.
if flags.JQFilter != "" {
q, err := gojq.Parse(flags.JQFilter)
if err != nil {
return output.ErrJQValidation(err)
}
if _, err := gojq.Compile(q, gojq.WithEnvironLoader(os.Environ)); err != nil {
return output.ErrJQValidation(err)
}
if flags.IDsOnly {
return output.ErrJQConflict("--ids-only")
}
if flags.Count {
return output.ErrJQConflict("--count")
}
}

cmd.SetContext(appctx.WithApp(cmd.Context(), app))
return nil
},
Expand Down Expand Up @@ -161,6 +180,7 @@ func NewRootCmd() *cobra.Command {
cmd.PersistentFlags().BoolVar(&flags.IDsOnly, "ids-only", false, "Output only IDs")
cmd.PersistentFlags().BoolVar(&flags.Count, "count", false, "Output only count")
cmd.PersistentFlags().BoolVar(&flags.Agent, "agent", false, "Agent mode (JSON + quiet)")
cmd.PersistentFlags().StringVar(&flags.JQFilter, "jq", "", "Apply jq filter to JSON output (built-in, no external jq required; implies --json)")

// Context flags
cmd.PersistentFlags().StringVarP(&flags.Project, "project", "p", "", "Project ID or name")
Expand Down Expand Up @@ -292,13 +312,23 @@ func Execute() {
// Convert error to structured output
apiErr := output.AsError(err)

// Try to use app.Err() if app is available (for --stats support)
if app := appctx.FromContext(executedCmd.Context()); app != nil {
_ = app.Err(err)
os.Exit(apiErr.ExitCode())
// jq-related errors (validation failures, unsupported commands, conflicts)
// must never be fed through the jq filter. Skip app.Err() entirely and
// render with a plain writer.
disableJQ := output.IsJQError(err)
if !disableJQ {
if app := appctx.FromContext(executedCmd.Context()); app != nil {
if writeErr := app.Err(err); writeErr == nil {
os.Exit(apiErr.ExitCode())
}
// app.Err() write failed (e.g. jq runtime error on the error
// envelope, or broken pipe). Disable jq in the fallback writer
// to avoid replaying the same failure.
disableJQ = true
}
}

// Fallback: output error directly (app not available, e.g., during setup)
// Fallback: output error directly (app not available, or jq bypass needed)
pf := cmd.PersistentFlags()
format := output.FormatAuto // Default to auto (TTY → styled, non-TTY → JSON)
agent, _ := pf.GetBool("agent")
Expand All @@ -308,6 +338,14 @@ func Execute() {
styled, _ := pf.GetBool("styled")
md, _ := pf.GetBool("md")
jsonFlag, _ := pf.GetBool("json")
jqFilter, _ := pf.GetString("jq")
hadJQ := jqFilter != ""

// Strip jq filter when disabled (jq-about-jq errors OR app.Err() write failure).
// hadJQ preserves the "--jq implies --json" format decision even after zeroing.
if disableJQ {
jqFilter = ""
}

if agent || quiet {
format = output.FormatQuiet
Expand All @@ -319,13 +357,14 @@ func Execute() {
format = output.FormatStyled
} else if md {
format = output.FormatMarkdown
} else if jsonFlag {
} else if jsonFlag || hadJQ {
format = output.FormatJSON
}

writer := output.New(output.Options{
Format: format,
Writer: os.Stdout,
Format: format,
Writer: os.Stdout,
JQFilter: jqFilter,
})
_ = writer.Err(err)

Expand Down Expand Up @@ -491,6 +530,9 @@ func isMachineConsumer(root *cobra.Command) bool {
return true
}
}
if jq, _ := pf.GetString("jq"); jq != "" {
return true
}
fi, err := os.Stdout.Stat()
if err == nil && (fi.Mode()&os.ModeCharDevice) == 0 {
return true
Expand Down
93 changes: 93 additions & 0 deletions internal/cli/root_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,87 @@ func TestResolvePreferences(t *testing.T) {
}
}

// isolateRootTest sets env vars for hermetic root tests.
func isolateRootTest(t *testing.T) {
t.Helper()
t.Setenv("BASECAMP_NO_KEYRING", "1")
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
}

func TestJQInvalidExpressionRejectedBeforeRunE(t *testing.T) {
isolateRootTest(t)

root := NewRootCmd()
root.AddCommand(commands.NewConfigCmd())
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"config", "show", "--jq", ".[invalid"})

err := root.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid --jq expression")
}

func TestJQCompileErrorRejectedBeforeRunE(t *testing.T) {
isolateRootTest(t)

root := NewRootCmd()
root.AddCommand(commands.NewConfigCmd())
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"config", "show", "--jq", "$__loc__"})

err := root.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid --jq expression")
}

func TestJQWithIDsOnlyConflict(t *testing.T) {
isolateRootTest(t)

root := NewRootCmd()
root.AddCommand(commands.NewConfigCmd())
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"config", "show", "--jq", ".data", "--ids-only"})

err := root.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot use --jq with --ids-only")
}

func TestJQWithCountConflict(t *testing.T) {
isolateRootTest(t)

root := NewRootCmd()
root.AddCommand(commands.NewConfigCmd())
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"config", "show", "--jq", ".data", "--count"})

err := root.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "cannot use --jq with --count")
}

func TestIsMachineConsumerWithJQ(t *testing.T) {
root := NewRootCmd()
_ = root.PersistentFlags().Set("jq", ".data")

assert.True(t, isMachineConsumer(root))
}

func TestIsMachineConsumerWithoutJQ(t *testing.T) {
// Without any flags and with stdout as a terminal (in tests it's not a terminal),
// the piped stdout should make this return true in test context.
root := NewRootCmd()

// isMachineConsumer checks stdout which in tests is not a TTY.
// This is fine — it returns true because stdout is piped.
assert.True(t, isMachineConsumer(root))
}

func TestVersionSubcommand(t *testing.T) {
orig := version.Version
version.Version = "1.2.3"
Expand All @@ -147,3 +228,15 @@ func TestVersionSubcommand(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "basecamp version 1.2.3\n", buf.String())
}

func TestVersionWithJQReturnsUsageError(t *testing.T) {
root := NewRootCmd()
root.AddCommand(commands.NewVersionCmd())
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"version", "--jq", ".x"})

err := root.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "--jq is not supported by the version command")
}
8 changes: 6 additions & 2 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,8 @@ Output modes:
}

// Output raw token by default for backwards compatibility with shell scripts.
// Only use JSON envelope when --json is explicitly requested.
if app.Flags.JSON || app.Flags.Agent {
// Only use JSON envelope when --json/--agent/--jq is explicitly requested.
if app.Flags.JSON || app.Flags.Agent || app.Flags.JQFilter != "" {
return app.OK(map[string]string{"token": token})
}

Expand Down Expand Up @@ -241,6 +241,10 @@ func buildLoginCmd(use string) *cobra.Command {
return fmt.Errorf("app not initialized")
}

if app.Flags.JQFilter != "" {
return output.ErrJQNotSupported("the login command")
}

if deviceCode {
remote = true
}
Expand Down
Loading
Loading