Skip to content

Commit ecf7705

Browse files
authored
feat!: agent-forward CLI — TTY-aware output, structured errors, --fields, table formatting [REL-12752] (#660)
* adding agent flag for agent telemtry * (feat) adding TTY detection for auto-JSON output * improvements * addressing pr feedback * feat(REL-12754): Agent friendly error handling (#661) * [feat] Agent friendly error handling * feat(REL-12755): adding --fields flag (#662) * [feat] adding --fields flag * feat(REL-15756): updating agent friendly and improved rich text output (#663) feat(REL-15756) updating agent friendly and improved rich text output * fix: update stale test assertions for key-value plaintext format Three tests in root_test.go still asserted the old `name (key)` bullet format, but the toggle-on command now passes ResourceName: "flags" which triggers key-value output. Update to match the actual output shape. Made-with: Cursor * refactor: move fields into CmdOutputOpts, warn on plaintext --fields Remove the standalone `fields []string` positional parameter from CmdOutput and pass fields exclusively through CmdOutputOpts.Fields. This eliminates the dual-path API and simplifies every call site. Also emit a stderr warning when --fields is used with plaintext output, since the flag is silently ignored in that mode. Made-with: Cursor * docs: document error shape and table format breaking changes in CHANGELOG Add entries for the error JSON shape change (new statusCode/suggestion fields, message casing) and the plaintext table format change. Both are breaking changes that should be called out for v3.0. Made-with: Cursor
1 parent 5126f68 commit ecf7705

26 files changed

Lines changed: 2731 additions & 75 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### ⚠ BREAKING CHANGES
6+
7+
* 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.
8+
* 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.
9+
* 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`.
10+
311
## [2.2.0](https://github.com/launchdarkly/ldcli/compare/v2.1.0...v2.2.0) (2026-02-20)
412

513

README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Supported settings:
6767
* `base-uri` LaunchDarkly base URI (default "https://app.launchdarkly.com")
6868
- `environment`: Default environment key
6969
- `flag`: Default feature flag key
70-
- `output`: Command response output format in either JSON or plain text
70+
- `output`: Output format: json or plaintext (default: plaintext in a terminal, json otherwise)
7171
- `project`: Default project key
7272

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

9191
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.
9292

93+
### Output format defaults
94+
95+
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).
96+
97+
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.
98+
99+
**`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`.
100+
101+
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.
102+
93103
## Commands
94104

95105
LaunchDarkly CLI commands:

cmd/analytics/analytics_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import (
88
)
99

1010
type mockEnvChecker struct {
11-
envVars map[string]string
12-
stdinTerminal bool
11+
envVars map[string]string
12+
stdinTerminal bool
1313
stdoutTerminal bool
1414
}
1515

cmd/cliflags/flags.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ func GetOutputKind(cmd *cobra.Command) string {
1313
return viper.GetString(OutputFlag)
1414
}
1515

16+
// GetFields returns the list of fields to include in JSON output, or nil if not specified.
17+
func GetFields(cmd *cobra.Command) []string {
18+
fields, err := cmd.Root().PersistentFlags().GetStringSlice(FieldsFlag)
19+
if err != nil {
20+
return nil
21+
}
22+
return fields
23+
}
24+
1625
const (
1726
BaseURIDefault = "https://app.launchdarkly.com"
1827
DevStreamURIDefault = "https://stream.launchdarkly.com"
@@ -27,6 +36,7 @@ const (
2736
DevStreamURIFlag = "dev-stream-uri"
2837
EmailsFlag = "emails"
2938
EnvironmentFlag = "environment"
39+
FieldsFlag = "fields"
3040
FlagFlag = "flag"
3141
JSONFlag = "json"
3242
OutputFlag = "output"
@@ -42,9 +52,10 @@ const (
4252
CorsOriginFlagDescription = "Allowed CORS origin. Use '*' for all origins (default: '*')"
4353
DevStreamURIDescription = "Streaming service endpoint that the dev server uses to obtain authoritative flag data. This may be a LaunchDarkly or Relay Proxy endpoint"
4454
EnvironmentFlagDescription = "Default environment key"
55+
FieldsFlagDescription = "Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)"
4556
FlagFlagDescription = "Default feature flag key"
4657
JSONFlagDescription = "Output JSON format (shorthand for --output json)"
47-
OutputFlagDescription = "Command response output format in either JSON or plain text"
58+
OutputFlagDescription = "Output format: json or plaintext (default: plaintext in a terminal, json otherwise)"
4859
PortFlagDescription = "Port for the dev server to run on"
4960
ProjectFlagDescription = "Default project key"
5061
SyncOnceFlagDescription = "Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied."

cmd/cmdtest.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ var StubbedSuccessResponse = `{
1919
"name": "test-name"
2020
}`
2121

22+
// CallCmd runs the root command for integration-style tests. It passes isTerminal always true so
23+
// the default --output matches an interactive terminal (plaintext); non-TTY JSON defaults are
24+
// covered in root_test.go.
2225
func CallCmd(
2326
t *testing.T,
2427
clients APIClients,
@@ -31,6 +34,8 @@ func CallCmd(
3134
clients,
3235
"test",
3336
false,
37+
func() bool { return true },
38+
nil,
3439
)
3540
cmd := rootCmd.Cmd()
3641
require.NoError(t, err)

cmd/config/testdata/help.golden

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Supported settings:
99
- `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
1010
- `environment`: Default environment key
1111
- `flag`: Default feature flag key
12-
- `output`: Command response output format in either JSON or plain text
12+
- `output`: Output format: json or plaintext (default: plaintext in a terminal, json otherwise)
1313
- `port`: Port for the dev server to run on
1414
- `project`: Default project key
1515
- `sync-once`: Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied.
@@ -27,5 +27,6 @@ Global Flags:
2727
--access-token string LaunchDarkly access token with write-level access
2828
--analytics-opt-out Opt out of analytics tracking
2929
--base-uri string LaunchDarkly base URI (default "https://app.launchdarkly.com")
30+
--fields strings Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)
3031
--json Output JSON format (shorthand for --output json)
31-
-o, --output string Command response output format in either JSON or plain text (default "plaintext")
32+
-o, --output string Output format: json or plaintext (default: plaintext in a terminal, json otherwise) (default "plaintext")

cmd/flags/archive.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,10 @@ func makeArchiveRequest(client resources.Client) func(*cobra.Command, []string)
5151
return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd))
5252
}
5353

54-
output, err := output.CmdOutput("update", cliflags.GetOutputKind(cmd), res)
54+
output, err := output.CmdOutput("update", cliflags.GetOutputKind(cmd), res, output.CmdOutputOpts{
55+
Fields: cliflags.GetFields(cmd),
56+
ResourceName: "flags",
57+
})
5558
if err != nil {
5659
return errors.NewError(err.Error())
5760
}

cmd/flags/archive_test.go

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ func TestArchive(t *testing.T) {
1515
mockClient := &resources.MockClient{
1616
Response: []byte(`{
1717
"key": "test-flag",
18-
"name": "test flag"
18+
"name": "test flag",
19+
"kind": "boolean",
20+
"archived": true
1921
}`),
2022
}
2123

22-
t.Run("succeeds with valid inputs", func(t *testing.T) {
24+
t.Run("succeeds with plaintext output", func(t *testing.T) {
2325
args := []string{
2426
"flags", "archive",
2527
"--access-token", "abcd1234",
@@ -37,8 +39,122 @@ func TestArchive(t *testing.T) {
3739

3840
require.NoError(t, err)
3941
assert.Equal(t, `[{"op": "replace", "path": "/archived", "value": true}]`, string(mockClient.Input))
40-
assert.Equal(t, "Successfully updated test flag (test-flag)\n", string(output))
42+
assert.Contains(t, string(output), "Successfully updated\n\nKey:")
43+
assert.Contains(t, string(output), "test-flag")
44+
assert.Contains(t, string(output), "Name:")
45+
assert.Contains(t, string(output), "test flag")
46+
assert.NotContains(t, string(output), "* ")
4147
})
48+
49+
t.Run("succeeds with JSON output", func(t *testing.T) {
50+
args := []string{
51+
"flags", "archive",
52+
"--access-token", "abcd1234",
53+
"--flag", "test-flag",
54+
"--project", "test-proj",
55+
"--output", "json",
56+
}
57+
output, err := cmd.CallCmd(
58+
t,
59+
cmd.APIClients{
60+
ResourcesClient: mockClient,
61+
},
62+
analytics.NoopClientFn{}.Tracker(),
63+
args,
64+
)
65+
66+
require.NoError(t, err)
67+
assert.JSONEq(t, `{"key":"test-flag","name":"test flag","kind":"boolean","archived":true}`, string(output))
68+
})
69+
70+
t.Run("succeeds with --json shorthand", func(t *testing.T) {
71+
args := []string{
72+
"flags", "archive",
73+
"--access-token", "abcd1234",
74+
"--flag", "test-flag",
75+
"--project", "test-proj",
76+
"--json",
77+
}
78+
output, err := cmd.CallCmd(
79+
t,
80+
cmd.APIClients{
81+
ResourcesClient: mockClient,
82+
},
83+
analytics.NoopClientFn{}.Tracker(),
84+
args,
85+
)
86+
87+
require.NoError(t, err)
88+
assert.JSONEq(t, `{"key":"test-flag","name":"test flag","kind":"boolean","archived":true}`, string(output))
89+
})
90+
91+
t.Run("filters JSON output with --fields", func(t *testing.T) {
92+
args := []string{
93+
"flags", "archive",
94+
"--access-token", "abcd1234",
95+
"--flag", "test-flag",
96+
"--project", "test-proj",
97+
"--output", "json",
98+
"--fields", "key,name",
99+
}
100+
output, err := cmd.CallCmd(
101+
t,
102+
cmd.APIClients{
103+
ResourcesClient: mockClient,
104+
},
105+
analytics.NoopClientFn{}.Tracker(),
106+
args,
107+
)
108+
109+
require.NoError(t, err)
110+
assert.JSONEq(t, `{"key":"test-flag","name":"test flag"}`, string(output))
111+
})
112+
113+
t.Run("filters JSON output with --json and --fields", func(t *testing.T) {
114+
args := []string{
115+
"flags", "archive",
116+
"--access-token", "abcd1234",
117+
"--flag", "test-flag",
118+
"--project", "test-proj",
119+
"--json",
120+
"--fields", "key,name",
121+
}
122+
output, err := cmd.CallCmd(
123+
t,
124+
cmd.APIClients{
125+
ResourcesClient: mockClient,
126+
},
127+
analytics.NoopClientFn{}.Tracker(),
128+
args,
129+
)
130+
131+
require.NoError(t, err)
132+
assert.JSONEq(t, `{"key":"test-flag","name":"test flag"}`, string(output))
133+
})
134+
135+
t.Run("ignores --fields with plaintext output", func(t *testing.T) {
136+
args := []string{
137+
"flags", "archive",
138+
"--access-token", "abcd1234",
139+
"--flag", "test-flag",
140+
"--project", "test-proj",
141+
"--fields", "key",
142+
}
143+
output, err := cmd.CallCmd(
144+
t,
145+
cmd.APIClients{
146+
ResourcesClient: mockClient,
147+
},
148+
analytics.NoopClientFn{}.Tracker(),
149+
args,
150+
)
151+
152+
require.NoError(t, err)
153+
assert.Contains(t, string(output), "Successfully updated")
154+
assert.Contains(t, string(output), "Key:")
155+
assert.Contains(t, string(output), "test-flag")
156+
})
157+
42158
t.Run("returns error with missing flags", func(t *testing.T) {
43159
args := []string{
44160
"flags", "archive",
@@ -55,6 +171,6 @@ func TestArchive(t *testing.T) {
55171
)
56172

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

cmd/flags/toggle.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ func runE(client resources.Client) func(*cobra.Command, []string) error {
7373
return output.NewCmdOutputError(err, cliflags.GetOutputKind(cmd))
7474
}
7575

76-
output, err := output.CmdOutput("update", cliflags.GetOutputKind(cmd), res)
76+
output, err := output.CmdOutput("update", cliflags.GetOutputKind(cmd), res, output.CmdOutputOpts{
77+
Fields: cliflags.GetFields(cmd),
78+
ResourceName: "flags",
79+
})
7780
if err != nil {
7881
return errors.NewError(err.Error())
7982
}

0 commit comments

Comments
 (0)