Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### ⚠ BREAKING CHANGES

* When stdout is not a TTY, the default `--output` format is now **json** instead of plaintext. Scripts that assumed plaintext when output was piped or redirected should set `LD_OUTPUT=plaintext`, run `ldcli config --set output plaintext`, or pass `--output plaintext` (or `--output json` explicitly if you want JSON regardless of TTY). You can also set **`FORCE_TTY`** or **`LD_FORCE_TTY`** to any non-empty value to keep plaintext as the default when stdout is not a TTY, without changing the saved `output` setting.

## [2.2.0](https://github.com/launchdarkly/ldcli/compare/v2.1.0...v2.2.0) (2026-02-20)


Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Supported settings:
* `base-uri` LaunchDarkly base URI (default "https://app.launchdarkly.com")
- `environment`: Default environment key
- `flag`: Default feature flag key
- `output`: Command response output format in either JSON or plain text
- `output`: Output format: json or plaintext (default: plaintext in a terminal, json otherwise)
- `project`: Default project key

Available `config` commands:
Expand All @@ -90,6 +90,16 @@ ldcli config --set access-token api-00000000-0000-0000-0000-000000000000

Running this command creates a configuration file located at `$XDG_CONFIG_HOME/ldcli/config.yml` with the access token. Subsequent commands read from this file, so you do not need to specify the access token each time.

### Output format defaults

When you do not pass `--output` or `--json`, the default format depends on whether standard output is a terminal: **plaintext** in an interactive terminal, **json** when stdout is not a TTY (for example when piped, in CI, or in agent environments).

To force the plaintext default even when stdout is not a TTY, set either **`FORCE_TTY`** or **`LD_FORCE_TTY`** to any non-empty value (similar to tools that use `NO_COLOR`). That only affects the default; explicit `--output`, `--json`, `LD_OUTPUT`, and the `output` setting in your config file still apply.

**`LD_OUTPUT`** is the same setting as `output` in the config file, exposed as an environment variable (see the `LD_` prefix above). It is not new with TTY detection; the test suite locks in that it overrides the non-TTY JSON default when set to `plaintext`.

Effective output is resolved in this order: **`--json`** (if set, wins over `--output` when both are present), then **`--output`**, then **`LD_OUTPUT`**, then the **`output`** value from your config file, then the TTY-based default above.

## Commands

LaunchDarkly CLI commands:
Expand Down
4 changes: 2 additions & 2 deletions cmd/analytics/analytics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import (
)

type mockEnvChecker struct {
envVars map[string]string
stdinTerminal bool
envVars map[string]string
stdinTerminal bool
stdoutTerminal bool
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/cliflags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const (
EnvironmentFlagDescription = "Default environment key"
FlagFlagDescription = "Default feature flag key"
JSONFlagDescription = "Output JSON format (shorthand for --output json)"
OutputFlagDescription = "Command response output format in either JSON or plain text"
OutputFlagDescription = "Output format: json or plaintext (default: plaintext in a terminal, json otherwise)"
PortFlagDescription = "Port for the dev server to run on"
ProjectFlagDescription = "Default project key"
SyncOnceFlagDescription = "Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied."
Expand Down
5 changes: 5 additions & 0 deletions cmd/cmdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ var StubbedSuccessResponse = `{
"name": "test-name"
}`

// CallCmd runs the root command for integration-style tests. It passes isTerminal always true so
// the default --output matches an interactive terminal (plaintext); non-TTY JSON defaults are
// covered in root_test.go.
func CallCmd(
t *testing.T,
clients APIClients,
Expand All @@ -31,6 +34,8 @@ func CallCmd(
clients,
"test",
false,
func() bool { return true },
nil,
)
cmd := rootCmd.Cmd()
require.NoError(t, err)
Expand Down
4 changes: 2 additions & 2 deletions cmd/config/testdata/help.golden
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Supported settings:
- `dev-stream-uri`: Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint
- `environment`: Default environment key
- `flag`: Default feature flag key
- `output`: Command response output format in either JSON or plain text
- `output`: Output format: json or plaintext (default: plaintext in a terminal, json otherwise)
- `port`: Port for the dev server to run on
- `project`: Default project key
- `sync-once`: Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied.
Expand All @@ -28,4 +28,4 @@ Global Flags:
--analytics-opt-out Opt out of analytics tracking
--base-uri string LaunchDarkly base URI (default "https://app.launchdarkly.com")
--json Output JSON format (shorthand for --output json)
-o, --output string Command response output format in either JSON or plain text (default "plaintext")
-o, --output string Output format: json or plaintext (default: plaintext in a terminal, json otherwise) (default "plaintext")
39 changes: 38 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term"

cmdAnalytics "github.com/launchdarkly/ldcli/cmd/analytics"
"github.com/launchdarkly/ldcli/cmd/cliflags"
Expand Down Expand Up @@ -81,13 +82,40 @@ func init() {
cobra.AddTemplateFunc("HasOptionalFlags", HasOptionalFlags)
}

// forceTTYDefaultOutput is true when FORCE_TTY or LD_FORCE_TTY is non-empty, so the default
// --output is plaintext even if stdout is not a TTY (similar to NO_COLOR). Explicit --output,
// --json, LD_OUTPUT, and config file values still take precedence via Viper/Cobra after parse.
//
// getenv, if non-nil, is used only for those two keys (tests can inject without mutating
// process environment). If nil, os.Getenv is used.
func forceTTYDefaultOutput(getenv func(string) string) bool {
lookup := getenv
if lookup == nil {
lookup = os.Getenv
}
return lookup("FORCE_TTY") != "" || lookup("LD_FORCE_TTY") != ""
}

// NewRootCommand constructs the ldcli root command tree.
//
// isTerminal must be non-nil; it should reflect whether stdout is a TTY (see Execute). When it
// returns false, the default --output is json unless forceTTYDefaultOutput applies.
//
// getenv is optional: when non-nil, it is used to read FORCE_TTY and LD_FORCE_TTY only; when
// nil, os.Getenv is used. Viper still reads LD_OUTPUT and other LD_ vars from the real
// environment.
func NewRootCommand(
configService config.Service,
analyticsTrackerFn analytics.TrackerFn,
clients APIClients,
version string,
useConfigFile bool,
isTerminal func() bool,
getenv func(string) string,
) (*RootCmd, error) {
if isTerminal == nil {
return nil, errors.New("NewRootCommand: isTerminal must not be nil")
}
cmd := &cobra.Command{
Use: "ldcli",
Short: "LaunchDarkly CLI",
Expand Down Expand Up @@ -188,10 +216,17 @@ func NewRootCommand(
return nil, err
}

// When stdout is not a TTY (e.g. piped, CI, agent), default to JSON unless FORCE_TTY or
// LD_FORCE_TTY is set (any non-empty value), like NO_COLOR.
defaultOutput := "plaintext"
if !forceTTYDefaultOutput(getenv) && !isTerminal() {
defaultOutput = "json"
}

cmd.PersistentFlags().StringP(
cliflags.OutputFlag,
"o",
"plaintext",
defaultOutput,
cliflags.OutputFlagDescription,
)
err = viper.BindPFlag(cliflags.OutputFlag, cmd.PersistentFlags().Lookup(cliflags.OutputFlag))
Expand Down Expand Up @@ -252,6 +287,8 @@ func Execute(version string) {
clients,
version,
true,
func() bool { return term.IsTerminal(int(os.Stdout.Fd())) },
nil,
)
if err != nil {
log.Fatal(err)
Expand Down
Loading
Loading