Skip to content

Commit 9872c28

Browse files
nieblaraCopilot
andauthored
feat: add --output markdown format (#678)
* 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 * feat: add --dry-run flag to toggle-on, toggle-off, and archive commands The API already supports dryRun as a query parameter on PATCH /api/v2/flags. The auto-generated `flags update` command exposes it, but the hand-rolled toggle and archive commands passed nil for query params. This wires --dry-run through to MakeRequest on all three. Also adds a Query field to MockClient so tests can verify query params. Made-with: Cursor * fix: use toggle-off in TestToggleOff dry-run tests Copy-paste error had both dry-run subtests invoking toggle-on instead of toggle-off, so toggle-off's --dry-run path was never exercised. Made-with: Cursor * feat: register markdown as a valid output kind Add "markdown" alongside "json" and "plaintext" as accepted values for the --output flag. Update validation, help text, and related tests. Made-with: Cursor * feat: add markdown rendering for resource output Implement markdown formatting for singular and list responses: - Flags get a rich view with environment status table and metadata - Known resources use GFM tables for lists, key-value bullets for detail - Unknown resources fall back to headings / bullet lists - Extract paginationSuffix helper to share between plaintext and markdown Made-with: Cursor * test: add markdown output tests for flag commands Cover --output markdown through archive, toggle-on, and toggle-off command tests to verify end-to-end markdown rendering. Made-with: Cursor --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nieblara <17378597+nieblara@users.noreply.github.com>
1 parent ad4ab2d commit 9872c28

11 files changed

Lines changed: 887 additions & 30 deletions

File tree

cmd/cliflags/flags.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const (
5757
FieldsFlagDescription = "Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)"
5858
FlagFlagDescription = "Default feature flag key"
5959
JSONFlagDescription = "Output JSON format (shorthand for --output json)"
60-
OutputFlagDescription = "Output format: json or plaintext (default: plaintext in a terminal, json otherwise)"
60+
OutputFlagDescription = "Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise)"
6161
PortFlagDescription = "Port for the dev server to run on"
6262
ProjectFlagDescription = "Default project key"
6363
SyncOnceFlagDescription = "Only sync new projects. Existing projects will neither be resynced nor have overrides specified by CLI flags applied."

cmd/config/testdata/help.golden

Lines changed: 2 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`: Output format: json or plaintext (default: plaintext in a terminal, json otherwise)
12+
- `output`: Output format: json, plaintext, or markdown (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.
@@ -29,4 +29,4 @@ Global Flags:
2929
--base-uri string LaunchDarkly base URI (default "https://app.launchdarkly.com")
3030
--fields strings Comma-separated list of top-level fields to include in JSON output (e.g., --fields key,name,kind)
3131
--json Output JSON format (shorthand for --output json)
32-
-o, --output string Output format: json or plaintext (default: plaintext in a terminal, json otherwise) (default "plaintext")
32+
-o, --output string Output format: json, plaintext, or markdown (default: plaintext in a terminal, json otherwise) (default "plaintext")

cmd/flags/archive_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,29 @@ func TestArchive(t *testing.T) {
155155
assert.Contains(t, string(output), "test-flag")
156156
})
157157

158+
t.Run("succeeds with markdown output", func(t *testing.T) {
159+
args := []string{
160+
"flags", "archive",
161+
"--access-token", "abcd1234",
162+
"--flag", "test-flag",
163+
"--project", "test-proj",
164+
"--output", "markdown",
165+
}
166+
output, err := cmd.CallCmd(
167+
t,
168+
cmd.APIClients{
169+
ResourcesClient: mockClient,
170+
},
171+
analytics.NoopClientFn{}.Tracker(),
172+
args,
173+
)
174+
175+
require.NoError(t, err)
176+
assert.Contains(t, string(output), "Successfully updated")
177+
assert.Contains(t, string(output), "## test-flag")
178+
assert.Contains(t, string(output), "- **Kind:** boolean")
179+
})
180+
158181
t.Run("passes dryRun query param when --dry-run is set", func(t *testing.T) {
159182
args := []string{
160183
"flags", "archive",

cmd/flags/toggle_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,31 @@ func TestToggleOn(t *testing.T) {
161161
assert.Contains(t, string(output), "test-flag")
162162
})
163163

164+
t.Run("succeeds with markdown output", func(t *testing.T) {
165+
args := []string{
166+
"flags", "toggle-on",
167+
"--access-token", "abcd1234",
168+
"--environment", "test-env",
169+
"--flag", "test-flag",
170+
"--project", "test-proj",
171+
"--output", "markdown",
172+
}
173+
output, err := cmd.CallCmd(
174+
t,
175+
cmd.APIClients{
176+
ResourcesClient: mockClient,
177+
},
178+
analytics.NoopClientFn{}.Tracker(),
179+
args,
180+
)
181+
182+
require.NoError(t, err)
183+
assert.Contains(t, string(output), "Successfully updated")
184+
assert.Contains(t, string(output), "## test-flag")
185+
assert.Contains(t, string(output), "- **Kind:** boolean")
186+
assert.Contains(t, string(output), "- **Temporary:** yes")
187+
})
188+
164189
t.Run("returns error with missing required flags", func(t *testing.T) {
165190
args := []string{
166191
"flags", "toggle-on",
@@ -332,6 +357,30 @@ func TestToggleOff(t *testing.T) {
332357
assert.Contains(t, string(output), "test-flag")
333358
})
334359

360+
t.Run("succeeds with markdown output", func(t *testing.T) {
361+
args := []string{
362+
"flags", "toggle-off",
363+
"--access-token", "abcd1234",
364+
"--environment", "test-env",
365+
"--flag", "test-flag",
366+
"--project", "test-proj",
367+
"--output", "markdown",
368+
}
369+
output, err := cmd.CallCmd(
370+
t,
371+
cmd.APIClients{
372+
ResourcesClient: mockClient,
373+
},
374+
analytics.NoopClientFn{}.Tracker(),
375+
args,
376+
)
377+
378+
require.NoError(t, err)
379+
assert.Contains(t, string(output), "Successfully updated")
380+
assert.Contains(t, string(output), "## test-flag")
381+
assert.Contains(t, string(output), "- **Kind:** boolean")
382+
})
383+
335384
t.Run("passes dryRun query param when --dry-run is set", func(t *testing.T) {
336385
args := []string{
337386
"flags", "toggle-off",

internal/config/config_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ func TestUpdate(t *testing.T) {
110110
t.Run("with an invalid output flag", func(t *testing.T) {
111111
_, _, err = c.Update([]string{"output", "invalid"})
112112

113-
assert.EqualError(t, err, "output is invalid. Use 'json' or 'plaintext'")
113+
assert.EqualError(t, err, "output is invalid. Use 'json', 'plaintext', or 'markdown'")
114114
})
115115

116116
t.Run("with an invalid analytics-opt-out flag", func(t *testing.T) {

internal/output/markdown.go

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
package output
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
)
8+
9+
// MarkdownTableOutput formats a slice of resources as a GitHub-flavored markdown table.
10+
func MarkdownTableOutput(items []resource, cols []ColumnDef) string {
11+
headers := make([]string, len(cols))
12+
separators := make([]string, len(cols))
13+
for i, col := range cols {
14+
headers[i] = col.Header
15+
separators[i] = "---"
16+
}
17+
18+
var sb strings.Builder
19+
sb.WriteString("| ")
20+
sb.WriteString(strings.Join(headers, " | "))
21+
sb.WriteString(" |\n| ")
22+
sb.WriteString(strings.Join(separators, " | "))
23+
sb.WriteString(" |")
24+
25+
for _, item := range items {
26+
vals := make([]string, len(cols))
27+
for i, col := range cols {
28+
vals[i] = escapeMDPipe(colValue(item, col))
29+
}
30+
sb.WriteString("\n| ")
31+
sb.WriteString(strings.Join(vals, " | "))
32+
sb.WriteString(" |")
33+
}
34+
35+
return sb.String()
36+
}
37+
38+
// MarkdownKeyValueOutput formats a single resource as a markdown bullet list of key-value pairs.
39+
func MarkdownKeyValueOutput(r resource, cols []ColumnDef) string {
40+
lines := make([]string, 0, len(cols))
41+
for _, col := range cols {
42+
val := colValue(r, col)
43+
lines = append(lines, fmt.Sprintf("- **%s:** %s", col.Header, val))
44+
}
45+
return strings.Join(lines, "\n")
46+
}
47+
48+
// MarkdownSingularOutput renders a single resource in markdown with a heading and metadata.
49+
// For flags it produces a rich view with environment table; for other resources it uses
50+
// the column registry or a generic fallback.
51+
func MarkdownSingularOutput(r resource, resourceName string) string {
52+
if resourceName == "flags" {
53+
return markdownFlagOutput(r)
54+
}
55+
56+
heading := markdownHeading(r)
57+
if cols := GetSingularColumns(resourceName); cols != nil {
58+
return heading + "\n\n" + MarkdownKeyValueOutput(r, cols)
59+
}
60+
return heading
61+
}
62+
63+
// MarkdownMultipleOutput renders a list of resources as a markdown table (if columns are
64+
// registered) or a bullet list.
65+
func MarkdownMultipleOutput(items []resource, resourceName string) string {
66+
if cols := GetListColumns(resourceName); cols != nil {
67+
return MarkdownTableOutput(items, cols)
68+
}
69+
70+
lines := make([]string, 0, len(items))
71+
for _, item := range items {
72+
lines = append(lines, fmt.Sprintf("- %s", SingularPlaintextOutputFn(item)))
73+
}
74+
return strings.Join(lines, "\n")
75+
}
76+
77+
func markdownFlagOutput(r resource) string {
78+
var sb strings.Builder
79+
80+
key := defaultFormat(r["key"])
81+
sb.WriteString("## ")
82+
sb.WriteString(key)
83+
84+
if desc, ok := r["description"]; ok && desc != nil && fmt.Sprint(desc) != "" {
85+
sb.WriteString("\n\n")
86+
sb.WriteString(fmt.Sprint(desc))
87+
}
88+
89+
envTable := markdownEnvTable(r)
90+
if envTable != "" {
91+
sb.WriteString("\n\n")
92+
sb.WriteString(envTable)
93+
}
94+
95+
if meta := markdownFlagMetadata(r); meta != "" {
96+
sb.WriteString("\n\n")
97+
sb.WriteString(meta)
98+
}
99+
100+
return sb.String()
101+
}
102+
103+
func markdownEnvTable(r resource) string {
104+
envMap, ok := r["environments"].(map[string]interface{})
105+
if !ok || len(envMap) == 0 {
106+
return ""
107+
}
108+
109+
variations := extractVariations(r)
110+
111+
keys := make([]string, 0, len(envMap))
112+
for k := range envMap {
113+
keys = append(keys, k)
114+
}
115+
sort.Strings(keys)
116+
117+
var sb strings.Builder
118+
sb.WriteString("| Environment | Status | Fallthrough | Rules |\n")
119+
sb.WriteString("| --- | --- | --- | --- |")
120+
121+
for _, envKey := range keys {
122+
envData, ok := envMap[envKey].(map[string]interface{})
123+
if !ok {
124+
continue
125+
}
126+
127+
status := "OFF"
128+
if on, ok := envData["on"].(bool); ok && on {
129+
status = "ON"
130+
}
131+
132+
fallthrough_ := resolveFallthrough(envData, variations)
133+
134+
rulesCount := 0
135+
if rules, ok := envData["rules"].([]interface{}); ok {
136+
rulesCount = len(rules)
137+
}
138+
139+
sb.WriteString(fmt.Sprintf("\n| %s | %s | %s | %d |",
140+
escapeMDPipe(envKey), status, escapeMDPipe(fallthrough_), rulesCount))
141+
}
142+
143+
return sb.String()
144+
}
145+
146+
func markdownFlagMetadata(r resource) string {
147+
var lines []string
148+
149+
if kind := r["kind"]; kind != nil {
150+
lines = append(lines, fmt.Sprintf("- **Kind:** %s", kind))
151+
}
152+
if temp, ok := r["temporary"].(bool); ok {
153+
lines = append(lines, fmt.Sprintf("- **Temporary:** %s", boolYesNo(temp)))
154+
}
155+
if tags, ok := r["tags"].([]interface{}); ok && len(tags) > 0 {
156+
strs := make([]string, len(tags))
157+
for i, t := range tags {
158+
strs[i] = fmt.Sprint(t)
159+
}
160+
lines = append(lines, fmt.Sprintf("- **Tags:** %s", strings.Join(strs, ", ")))
161+
}
162+
if maintainer := extractMaintainer(r); maintainer != "" {
163+
lines = append(lines, fmt.Sprintf("- **Maintainer:** %s", maintainer))
164+
}
165+
166+
return strings.Join(lines, "\n")
167+
}
168+
169+
func extractVariations(r resource) []variation {
170+
raw, ok := r["variations"].([]interface{})
171+
if !ok {
172+
return nil
173+
}
174+
vars := make([]variation, 0, len(raw))
175+
for _, v := range raw {
176+
m, ok := v.(map[string]interface{})
177+
if !ok {
178+
continue
179+
}
180+
name := ""
181+
if n, ok := m["name"].(string); ok {
182+
name = n
183+
}
184+
vars = append(vars, variation{
185+
Name: name,
186+
Value: m["value"],
187+
})
188+
}
189+
return vars
190+
}
191+
192+
type variation struct {
193+
Name string
194+
Value interface{}
195+
}
196+
197+
func resolveFallthrough(envData map[string]interface{}, variations []variation) string {
198+
ft, ok := envData["fallthrough"].(map[string]interface{})
199+
if !ok {
200+
return ""
201+
}
202+
varIdx, ok := ft["variation"].(float64)
203+
if !ok {
204+
return ""
205+
}
206+
idx := int(varIdx)
207+
if idx < 0 || idx >= len(variations) {
208+
return fmt.Sprintf("variation %d", idx)
209+
}
210+
v := variations[idx]
211+
if v.Name != "" {
212+
return fmt.Sprintf("%s (%v)", v.Name, v.Value)
213+
}
214+
return fmt.Sprintf("%v", v.Value)
215+
}
216+
217+
func extractMaintainer(r resource) string {
218+
m, ok := r["_maintainer"].(map[string]interface{})
219+
if !ok {
220+
return ""
221+
}
222+
if name, ok := m["name"].(string); ok && name != "" {
223+
return name
224+
}
225+
if email, ok := m["email"].(string); ok && email != "" {
226+
return email
227+
}
228+
return ""
229+
}
230+
231+
func markdownHeading(r resource) string {
232+
key := r["key"]
233+
name := r["name"]
234+
switch {
235+
case name != nil && key != nil:
236+
return fmt.Sprintf("## %s (%s)", fmt.Sprint(name), fmt.Sprint(key))
237+
case name != nil:
238+
return fmt.Sprintf("## %s", fmt.Sprint(name))
239+
case key != nil:
240+
return fmt.Sprintf("## %s", fmt.Sprint(key))
241+
default:
242+
return "## (unknown)"
243+
}
244+
}
245+
246+
func escapeMDPipe(s string) string {
247+
return strings.ReplaceAll(s, "|", "\\|")
248+
}

0 commit comments

Comments
 (0)