Skip to content

Commit 20dbe0b

Browse files
Add markdown output format to CLI list commands (#78)
Extend CLI output options with markdown formatting support across all list commands (agent, task, message, model, provider). Useful for generating documentation, reports, or copy-pasteable content. Co-authored-by: construct-agent <noreply@construct.sh>
1 parent 51364b4 commit 20dbe0b

3 files changed

Lines changed: 265 additions & 12 deletions

File tree

docs/cli_reference.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -199,14 +199,17 @@ construct agent list [flags]
199199
* `-m, --model <model-name|id>`: Filter agents by the model they use.
200200
* `-n, --name <string>`: Filter agents by name (supports partial matching).
201201
* `-l, --limit <number>`: Limit the number of results returned.
202-
* `--output <table|json|yaml>`: Specify the output format.
202+
* `--output <table|json|yaml|markdown>`: Specify the output format. Use `markdown` or `md` for markdown output.
203203

204204
**Examples**
205205

206206
```bash
207207
# List all agents in a table
208208
construct agent list
209209
210+
# Export agents as markdown for documentation
211+
construct agent list --output markdown
212+
210213
# Find all agents using a specific model
211214
construct agent ls --model "claude-3-5-sonnet"
212215
```
@@ -331,7 +334,7 @@ construct task list [flags]
331334
332335
* `-a, --agent <name|id>`: Filter tasks by the agent assigned to them.
333336
* `-l, --limit <number>`: Limit the number of results returned.
334-
* `--output <table|json|yaml>`: Specify the output format.
337+
* `--output <table|json|yaml|markdown>`: Specify the output format. Use `markdown` or `md` for markdown output.
335338
336339
**Examples**
337340
@@ -438,7 +441,7 @@ Lists messages, typically filtered by a specific task. Useful for reviewing or e
438441
* `-t, --task <task-id>`: (Recommended) Filter messages by task ID.
439442
* `-a, --agent <name|id>`: Filter by the agent that participated in the conversation.
440443
* `-r, --role <user|assistant>`: Filter messages by the role of the author.
441-
* `--output <table|json|yaml>`: Specify the output format.
444+
* `--output <table|json|yaml|markdown>`: Specify the output format.
442445

443446
**Examples**
444447

@@ -501,7 +504,7 @@ construct model list [flags]
501504
**Options**
502505

503506
* `-p, --provider <name|id>`: Filter models by their provider.
504-
* `--output <table|json|yaml>`: Specify the output format.
507+
* `--output <table|json|yaml|markdown>`: Specify the output format.
505508

506509
**Examples**
507510

@@ -565,7 +568,7 @@ construct provider list [flags]
565568
**Options**
566569

567570
* `-t, --type <openai|anthropic>`: Filter providers by type.
568-
* `--output <table|json|yaml>`: Specify the output format.
571+
* `--output <table|json|yaml|markdown>`: Specify the output format.
569572

570573
**Examples**
571574

frontend/cli/cmd/print.go

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ type RenderOptions struct {
2323
type OutputFormat string
2424

2525
const (
26-
OutputFormatJSON OutputFormat = "json"
27-
OutputFormatYAML OutputFormat = "yaml"
28-
OutputFormatTable OutputFormat = "table"
29-
OutputFormatCard OutputFormat = "card"
26+
OutputFormatJSON OutputFormat = "json"
27+
OutputFormatYAML OutputFormat = "yaml"
28+
OutputFormatTable OutputFormat = "table"
29+
OutputFormatCard OutputFormat = "card"
30+
OutputFormatMarkdown OutputFormat = "markdown"
3031
)
3132

3233
func (e *OutputFormat) String() string {
@@ -37,12 +38,15 @@ func (e *OutputFormat) String() string {
3738
}
3839

3940
func (e *OutputFormat) Set(v string) error {
41+
if v == "md" {
42+
v = "markdown"
43+
}
4044
switch v {
41-
case "json", "yaml", "table", "card":
45+
case "json", "yaml", "table", "card", "markdown":
4246
*e = OutputFormat(v)
4347
return nil
4448
default:
45-
return errors.New(`must be one of "json" or "yaml"`)
49+
return errors.New(`must be one of "json", "yaml", "table", "card", or "markdown"`)
4650
}
4751
}
4852

@@ -64,7 +68,7 @@ func addRenderOptions(cmd *cobra.Command, options *RenderOptions) {
6468
WithTableFormat(options)
6569
}
6670

67-
cmd.Flags().VarP(&options.Format, "output", "o", fmt.Sprintf("output format (json, yaml, table, card)(default: %s)", options.Format))
71+
cmd.Flags().VarP(&options.Format, "output", "o", fmt.Sprintf("output format (json, yaml, table, card, markdown) (default: %s)", options.Format))
6872
cmd.Flags().BoolVarP(&options.Wide, "wide", "w", false, "output verbosity (default: false)")
6973
cmd.Flags().BoolVarP(&options.NoHeaders, "no-headers", "", false, "do not print headers (default: false)")
7074
}
@@ -102,6 +106,11 @@ func (f *DefaultRenderer) Render(resources any, options *RenderOptions) (err err
102106
if err != nil {
103107
return err
104108
}
109+
case OutputFormatMarkdown:
110+
err = renderMarkdown(resources, options)
111+
if err != nil {
112+
return err
113+
}
105114
default:
106115
return fmt.Errorf("unsupported output format: %s", options.Format)
107116
}
@@ -334,6 +343,128 @@ func renderCard(resources any, options *RenderOptions) error {
334343
return nil
335344
}
336345

346+
func renderMarkdown(resources any, options *RenderOptions) error {
347+
if resources == nil {
348+
return nil
349+
}
350+
351+
value := reflect.ValueOf(resources)
352+
typ := reflect.TypeOf(resources)
353+
354+
if value.Kind() == reflect.Ptr {
355+
if value.IsNil() {
356+
return nil
357+
}
358+
value = value.Elem()
359+
typ = typ.Elem()
360+
}
361+
362+
var items []reflect.Value
363+
var itemType reflect.Type
364+
365+
if value.Kind() == reflect.Slice {
366+
if value.Len() == 0 {
367+
return nil
368+
}
369+
for i := 0; i < value.Len(); i++ {
370+
items = append(items, value.Index(i))
371+
}
372+
itemType = typ.Elem()
373+
} else {
374+
items = append(items, value)
375+
itemType = typ
376+
}
377+
378+
if itemType.Kind() == reflect.Ptr {
379+
itemType = itemType.Elem()
380+
}
381+
382+
if itemType.Kind() != reflect.Struct {
383+
return fmt.Errorf("renderMarkdown only supports struct types, got %v", itemType.Kind())
384+
}
385+
386+
var fields []reflect.StructField
387+
for i := 0; i < itemType.NumField(); i++ {
388+
field := itemType.Field(i)
389+
if includeField(field, options.Wide) {
390+
fields = append(fields, field)
391+
}
392+
}
393+
394+
if len(fields) == 0 {
395+
return nil
396+
}
397+
398+
for idx, item := range items {
399+
if item.Kind() == reflect.Ptr {
400+
if item.IsNil() {
401+
continue
402+
}
403+
item = item.Elem()
404+
}
405+
406+
for _, field := range fields {
407+
fieldValue := item.FieldByName(field.Name)
408+
if !fieldValue.IsValid() {
409+
continue
410+
}
411+
412+
valueStr := formatMarkdownValue(fieldValue)
413+
fmt.Printf("**%s:** %s\n", field.Name, valueStr)
414+
}
415+
416+
if idx < len(items)-1 {
417+
fmt.Print("\n---\n\n")
418+
}
419+
}
420+
421+
return nil
422+
}
423+
424+
func formatMarkdownValue(v reflect.Value) string {
425+
if !v.IsValid() {
426+
return ""
427+
}
428+
429+
switch v.Kind() {
430+
case reflect.Ptr:
431+
if v.IsNil() {
432+
return ""
433+
}
434+
return formatMarkdownValue(v.Elem())
435+
case reflect.String:
436+
return v.String()
437+
case reflect.Slice:
438+
if v.Len() == 0 {
439+
return ""
440+
}
441+
var parts []string
442+
for i := 0; i < v.Len(); i++ {
443+
parts = append(parts, formatMarkdownValue(v.Index(i)))
444+
}
445+
return strings.Join(parts, ", ")
446+
case reflect.Map:
447+
if v.Len() == 0 {
448+
return ""
449+
}
450+
var parts []string
451+
iter := v.MapRange()
452+
for iter.Next() {
453+
k := fmt.Sprint(iter.Key().Interface())
454+
val := formatMarkdownValue(iter.Value())
455+
parts = append(parts, fmt.Sprintf("%s=%s", k, val))
456+
}
457+
return strings.Join(parts, ", ")
458+
case reflect.Struct:
459+
if v.Type().String() == "time.Time" {
460+
return fmt.Sprint(v.Interface())
461+
}
462+
return fmt.Sprint(v.Interface())
463+
default:
464+
return fmt.Sprint(v.Interface())
465+
}
466+
}
467+
337468
func includeField(field reflect.StructField, wide bool) bool {
338469
return field.IsExported() && (field.Tag.Get("detail") == "default" || (wide && field.Tag.Get("detail") == "full"))
339470
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package cmd
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"os"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestRenderMarkdown(t *testing.T) {
12+
tests := []struct {
13+
name string
14+
input any
15+
options *RenderOptions
16+
expected []string
17+
}{
18+
{
19+
name: "single struct",
20+
input: &AgentDisplay{
21+
ID: "123",
22+
Name: "test-agent",
23+
Description: "A test agent",
24+
Model: "gpt-4",
25+
},
26+
options: &RenderOptions{Format: OutputFormatMarkdown},
27+
expected: []string{
28+
"**ID:** 123",
29+
"**Name:** test-agent",
30+
"**Description:** A test agent",
31+
"**Model:** gpt-4",
32+
},
33+
},
34+
{
35+
name: "slice of structs",
36+
input: []*AgentDisplay{
37+
{ID: "1", Name: "agent-one", Model: "gpt-4"},
38+
{ID: "2", Name: "agent-two", Model: "claude"},
39+
},
40+
options: &RenderOptions{Format: OutputFormatMarkdown},
41+
expected: []string{
42+
"**ID:** 1",
43+
"**Name:** agent-one",
44+
"---",
45+
"**ID:** 2",
46+
"**Name:** agent-two",
47+
},
48+
},
49+
{
50+
name: "nil input",
51+
input: nil,
52+
options: &RenderOptions{Format: OutputFormatMarkdown},
53+
expected: []string{},
54+
},
55+
{
56+
name: "empty slice",
57+
input: []*AgentDisplay{},
58+
options: &RenderOptions{Format: OutputFormatMarkdown},
59+
expected: []string{},
60+
},
61+
}
62+
63+
for _, tt := range tests {
64+
t.Run(tt.name, func(t *testing.T) {
65+
// Capture stdout
66+
old := os.Stdout
67+
r, w, _ := os.Pipe()
68+
os.Stdout = w
69+
70+
err := renderMarkdown(tt.input, tt.options)
71+
if err != nil {
72+
t.Fatalf("renderMarkdown() error = %v", err)
73+
}
74+
75+
w.Close()
76+
var buf bytes.Buffer
77+
io.Copy(&buf, r)
78+
os.Stdout = old
79+
80+
output := buf.String()
81+
82+
for _, exp := range tt.expected {
83+
if !strings.Contains(output, exp) {
84+
t.Errorf("expected output to contain %q, got:\n%s", exp, output)
85+
}
86+
}
87+
})
88+
}
89+
}
90+
91+
func TestOutputFormatSet(t *testing.T) {
92+
tests := []struct {
93+
input string
94+
expected OutputFormat
95+
wantErr bool
96+
}{
97+
{"json", OutputFormatJSON, false},
98+
{"yaml", OutputFormatYAML, false},
99+
{"table", OutputFormatTable, false},
100+
{"card", OutputFormatCard, false},
101+
{"markdown", OutputFormatMarkdown, false},
102+
{"md", OutputFormatMarkdown, false},
103+
{"invalid", "", true},
104+
}
105+
106+
for _, tt := range tests {
107+
t.Run(tt.input, func(t *testing.T) {
108+
var f OutputFormat
109+
err := f.Set(tt.input)
110+
if (err != nil) != tt.wantErr {
111+
t.Errorf("OutputFormat.Set(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
112+
return
113+
}
114+
if !tt.wantErr && f != tt.expected {
115+
t.Errorf("OutputFormat.Set(%q) = %v, want %v", tt.input, f, tt.expected)
116+
}
117+
})
118+
}
119+
}

0 commit comments

Comments
 (0)