Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# 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.
* Error responses now include `statusCode` (integer) and `suggestion` (string) fields in the JSON body. The `message` field for empty-body errors uses `http.StatusText` casing (e.g., `"Method Not Allowed"` instead of the previous `"method not allowed"`). If you parse error JSON from `ldcli`, update any assertions on the exact shape or casing.
* Plaintext list output for `flags`, `projects`, `environments`, `members`, and `segments` now renders as aligned tables instead of `* name (key)` bullets. Singular resources render as key-value blocks. If you parse plaintext output programmatically, switch to `--output json`.

## [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
15 changes: 14 additions & 1 deletion cmd/cliflags/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ func GetOutputKind(cmd *cobra.Command) string {
return viper.GetString(OutputFlag)
}

// GetFields returns the list of fields to include in JSON output, or nil if not specified.
func GetFields(cmd *cobra.Command) []string {
fields, err := cmd.Root().PersistentFlags().GetStringSlice(FieldsFlag)
if err != nil {
return nil
}
return fields
}

const (
BaseURIDefault = "https://app.launchdarkly.com"
DevStreamURIDefault = "https://stream.launchdarkly.com"
Expand All @@ -24,9 +33,11 @@ const (
CorsEnabledFlag = "cors-enabled"
CorsOriginFlag = "cors-origin"
DataFlag = "data"
DryRunFlag = "dry-run"
DevStreamURIFlag = "dev-stream-uri"
EmailsFlag = "emails"
EnvironmentFlag = "environment"
FieldsFlag = "fields"
FlagFlag = "flag"
JSONFlag = "json"
OutputFlag = "output"
Expand All @@ -41,10 +52,12 @@ const (
CorsEnabledFlagDescription = "Enable CORS headers for browser-based developer tools (default: false)"
CorsOriginFlagDescription = "Allowed CORS origin. Use '*' for all origins (default: '*')"
DevStreamURIDescription = "Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint"
DryRunFlagDescription = "Validate the change without persisting it. Returns a preview of the result."
EnvironmentFlagDescription = "Default environment key"
FieldsFlagDescription = "Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)"
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, plaintext, or markdown (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
5 changes: 3 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, plaintext, or markdown (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 @@ -27,5 +27,6 @@ Global Flags:
--access-token string LaunchDarkly access token with write-level access
--analytics-opt-out Opt out of analytics tracking
--base-uri string LaunchDarkly base URI (default "https://app.launchdarkly.com")
--fields strings Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)
--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, plaintext, or markdown (default: plaintext in a terminal, json otherwise) (default "plaintext")
13 changes: 11 additions & 2 deletions cmd/flags/archive.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,20 +38,27 @@ func makeArchiveRequest(client resources.Client) func(*cobra.Command, []string)
viper.GetString(cliflags.ProjectFlag),
viper.GetString(cliflags.FlagFlag),
)
var query url.Values
if dryRun, _ := cmd.Flags().GetBool(cliflags.DryRunFlag); dryRun {
query = url.Values{"dryRun": []string{"true"}}
}
res, err := client.MakeRequest(
viper.GetString(cliflags.AccessTokenFlag),
"PATCH",
path,
"application/json",
nil,
query,
[]byte(`[{"op": "replace", "path": "/archived", "value": true}]`),
false,
)
if err != nil {
return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd))
}

output, err := output.CmdOutput("update", cliflags.GetOutputKind(cmd), res)
output, err := output.CmdOutput("update", cliflags.GetOutputKind(cmd), res, output.CmdOutputOpts{
Fields: cliflags.GetFields(cmd),
ResourceName: "flags",
})
if err != nil {
return errors.NewError(err.Error())
}
Expand All @@ -72,4 +79,6 @@ func initArchiveFlags(cmd *cobra.Command) {
_ = cmd.MarkFlagRequired(cliflags.ProjectFlag)
_ = cmd.Flags().SetAnnotation(cliflags.ProjectFlag, "required", []string{"true"})
_ = viper.BindPFlag(cliflags.ProjectFlag, cmd.Flags().Lookup(cliflags.ProjectFlag))

cmd.Flags().Bool(cliflags.DryRunFlag, false, cliflags.DryRunFlagDescription)
}
190 changes: 186 additions & 4 deletions cmd/flags/archive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ func TestArchive(t *testing.T) {
mockClient := &resources.MockClient{
Response: []byte(`{
"key": "test-flag",
"name": "test flag"
"name": "test flag",
"kind": "boolean",
"archived": true
}`),
}

t.Run("succeeds with valid inputs", func(t *testing.T) {
t.Run("succeeds with plaintext output", func(t *testing.T) {
args := []string{
"flags", "archive",
"--access-token", "abcd1234",
Expand All @@ -37,8 +39,188 @@ func TestArchive(t *testing.T) {

require.NoError(t, err)
assert.Equal(t, `[{"op": "replace", "path": "/archived", "value": true}]`, string(mockClient.Input))
assert.Equal(t, "Successfully updated test flag (test-flag)\n", string(output))
assert.Contains(t, string(output), "Successfully updated\n\nKey:")
assert.Contains(t, string(output), "test-flag")
assert.Contains(t, string(output), "Name:")
assert.Contains(t, string(output), "test flag")
assert.NotContains(t, string(output), "* ")
})

t.Run("succeeds with JSON output", func(t *testing.T) {
args := []string{
"flags", "archive",
"--access-token", "abcd1234",
"--flag", "test-flag",
"--project", "test-proj",
"--output", "json",
}
output, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.JSONEq(t, `{"key":"test-flag","name":"test flag","kind":"boolean","archived":true}`, string(output))
})

t.Run("succeeds with --json shorthand", func(t *testing.T) {
args := []string{
"flags", "archive",
"--access-token", "abcd1234",
"--flag", "test-flag",
"--project", "test-proj",
"--json",
}
output, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.JSONEq(t, `{"key":"test-flag","name":"test flag","kind":"boolean","archived":true}`, string(output))
})

t.Run("filters JSON output with --fields", func(t *testing.T) {
args := []string{
"flags", "archive",
"--access-token", "abcd1234",
"--flag", "test-flag",
"--project", "test-proj",
"--output", "json",
"--fields", "key,name",
}
output, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.JSONEq(t, `{"key":"test-flag","name":"test flag"}`, string(output))
})

t.Run("filters JSON output with --json and --fields", func(t *testing.T) {
args := []string{
"flags", "archive",
"--access-token", "abcd1234",
"--flag", "test-flag",
"--project", "test-proj",
"--json",
"--fields", "key,name",
}
output, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.JSONEq(t, `{"key":"test-flag","name":"test flag"}`, string(output))
})

t.Run("ignores --fields with plaintext output", func(t *testing.T) {
args := []string{
"flags", "archive",
"--access-token", "abcd1234",
"--flag", "test-flag",
"--project", "test-proj",
"--fields", "key",
}
output, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.Contains(t, string(output), "Successfully updated")
assert.Contains(t, string(output), "Key:")
assert.Contains(t, string(output), "test-flag")
})

t.Run("succeeds with markdown output", func(t *testing.T) {
args := []string{
"flags", "archive",
"--access-token", "abcd1234",
"--flag", "test-flag",
"--project", "test-proj",
"--output", "markdown",
}
output, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.Contains(t, string(output), "Successfully updated")
assert.Contains(t, string(output), "## test-flag")
assert.Contains(t, string(output), "- **Kind:** boolean")
})

t.Run("passes dryRun query param when --dry-run is set", func(t *testing.T) {
args := []string{
"flags", "archive",
"--access-token", "abcd1234",
"--flag", "test-flag",
"--project", "test-proj",
"--output", "json",
"--dry-run",
}
_, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.Equal(t, "true", mockClient.Query.Get("dryRun"))
})

t.Run("does not pass dryRun query param by default", func(t *testing.T) {
args := []string{
"flags", "archive",
"--access-token", "abcd1234",
"--flag", "test-flag",
"--project", "test-proj",
"--output", "json",
}
_, err := cmd.CallCmd(
t,
cmd.APIClients{
ResourcesClient: mockClient,
},
analytics.NoopClientFn{}.Tracker(),
args,
)

require.NoError(t, err)
assert.Empty(t, mockClient.Query.Get("dryRun"))
})

t.Run("returns error with missing flags", func(t *testing.T) {
args := []string{
"flags", "archive",
Expand All @@ -55,6 +237,6 @@ func TestArchive(t *testing.T) {
)

assert.Error(t, err)
assert.Equal(t, "required flag(s) \"project\" not set. See `ldcli flags archive --help` for supported flags and usage.", err.Error())
assert.Contains(t, err.Error(), `required flag(s) "project" not set`)
})
}
Loading
Loading