Skip to content

Epic: Phase 4 - Output Consistency #4

@rianjs

Description

@rianjs

Phase 4: Output Consistency

Priority: P1 - User experience and composability
Estimated Sub-tasks: 4
Blocked By: Phase 3 (Restructuring)

Summary

Implement consistent output rendering across all commands. This includes creating a dedicated view package, replacing --json with --output, ensuring all commands support JSON output, and adding --no-color support.

Current Problems:

  • Some commands support --json, others don't
  • No --output flag with format options (table, json, plain)
  • Inconsistent table formatting
  • No plain format for piping to grep/cut/awk

Sub-Tasks

1. Implement internal/view Package for Output Rendering

  • Create internal/view/view.go
  • Implement Format type (table, json, plain)
  • Implement ValidateFormat() function
  • Implement Renderer struct with writer injection
  • Implement RenderTable() method with column width calculation
  • Implement RenderJSON() method with indentation
  • Implement RenderPlain() method (tab-separated for piping)
  • Implement Success() and Error() colored methods
  • Create internal/view/view_test.go

Implementation:

// internal/view/view.go
package view

import (
    "encoding/json"
    "fmt"
    "io"
    "os"
    "strings"
    
    "github.com/fatih/color"
)

type Format string

const (
    FormatTable Format = "table"
    FormatJSON  Format = "json"
    FormatPlain Format = "plain"
)

func ValidateFormat(format string) error {
    switch format {
    case "", string(FormatTable), string(FormatJSON), string(FormatPlain):
        return nil
    default:
        return fmt.Errorf("invalid output format: %q (valid: table, json, plain)", format)
    }
}

type Renderer struct {
    format  Format
    writer  io.Writer
    noColor bool
}

func NewRenderer(format Format, noColor bool) *Renderer {
    if noColor {
        color.NoColor = true
    }
    if format == "" {
        format = FormatTable
    }
    return &Renderer{
        format:  format,
        writer:  os.Stdout,
        noColor: noColor,
    }
}

func (r *Renderer) SetWriter(w io.Writer) {
    r.writer = w
}

func (r *Renderer) RenderTable(headers []string, rows [][]string) {
    switch r.format {
    case FormatJSON:
        r.renderTableAsJSON(headers, rows)
    case FormatPlain:
        r.renderTableAsPlain(rows)
    default:
        r.renderTableAsTable(headers, rows)
    }
}

func (r *Renderer) renderTableAsTable(headers []string, rows [][]string) {
    // Calculate column widths
    widths := make([]int, len(headers))
    for i, h := range headers {
        widths[i] = len(h)
    }
    for _, row := range rows {
        for i, cell := range row {
            if i < len(widths) && len(cell) > widths[i] {
                widths[i] = len(cell)
            }
        }
    }
    
    // Print headers
    for i, h := range headers {
        fmt.Fprintf(r.writer, "%-*s  ", widths[i], h)
    }
    fmt.Fprintln(r.writer)
    
    // Print separator
    for i, w := range widths {
        fmt.Fprintf(r.writer, "%s", strings.Repeat("-", w))
        if i < len(widths)-1 {
            fmt.Fprint(r.writer, "  ")
        }
    }
    fmt.Fprintln(r.writer)
    
    // Print rows
    for _, row := range rows {
        for i, cell := range row {
            if i < len(widths) {
                fmt.Fprintf(r.writer, "%-*s  ", widths[i], cell)
            }
        }
        fmt.Fprintln(r.writer)
    }
}

// Design decision: Plain format uses tabs for easy parsing with cut/awk
func (r *Renderer) renderTableAsPlain(rows [][]string) {
    for _, row := range rows {
        fmt.Fprintln(r.writer, strings.Join(row, "\t"))
    }
}

func (r *Renderer) renderTableAsJSON(headers []string, rows [][]string) {
    result := make([]map[string]string, len(rows))
    for i, row := range rows {
        item := make(map[string]string)
        for j, cell := range row {
            if j < len(headers) {
                item[strings.ToLower(headers[j])] = cell
            }
        }
        result[i] = item
    }
    r.RenderJSON(result)
}

func (r *Renderer) RenderJSON(v interface{}) error {
    data, err := json.MarshalIndent(v, "", "  ")
    if err != nil {
        return err
    }
    fmt.Fprintln(r.writer, string(data))
    return nil
}

func (r *Renderer) Success(msg string) {
    green := color.New(color.FgGreen)
    green.Fprintln(r.writer, "✓ "+msg)
}

func (r *Renderer) Error(msg string) {
    red := color.New(color.FgRed)
    red.Fprintln(r.writer, "✗ "+msg)
}

func (r *Renderer) Info(msg string) {
    fmt.Fprintln(r.writer, msg)
}

2. Replace Global --json Flag with --output Flag

  • Remove --json global flag from root command
  • Add --output / -o global flag (default: "table")
  • Support formats: table, json, plain
  • Update all commands to use new flag
  • Update README with new flag documentation

Breaking Change: This removes --json in favor of --output json

Implementation:

// internal/cmd/root/root.go
func NewCmdRoot() *cobra.Command {
    cmd := &cobra.Command{...}
    
    // Global flags
    cmd.PersistentFlags().StringP("output", "o", "table", "Output format: table, json, plain")
    cmd.PersistentFlags().Bool("no-color", false, "Disable colored output")
    cmd.PersistentFlags().StringP("config", "c", "", "Config file (default: ~/.config/slack-cli/config.yml)")
    
    return cmd
}

3. Fix JSON Output for All Commands

  • channels archive{"status": "archived", "channel_id": "..."}
  • channels unarchive{"status": "unarchived", "channel_id": "..."}
  • channels set-topic{"status": "updated", "channel_id": "...", "topic": "..."}
  • channels set-purpose{"status": "updated", "channel_id": "...", "purpose": "..."}
  • channels invite{"status": "invited", "channel_id": "...", "users": [...]}
  • messages delete{"status": "deleted", "channel": "...", "ts": "..."}
  • messages react{"status": "added", "emoji": "...", "ts": "..."}
  • messages unreact{"status": "removed", "emoji": "...", "ts": "..."}
  • messages update → Return full message object like send
  • config set-token{"status": "stored", "storage": "keychain|file"}
  • config delete-token{"status": "deleted"}

Implementation Pattern:

func runArchive(channelID string, opts *archiveOptions, c *client.Client) error {
    if err := c.ArchiveChannel(channelID); err != nil {
        return fmt.Errorf("failed to archive channel: %w", err)
    }
    
    renderer := view.NewRenderer(view.Format(opts.output), opts.noColor)
    
    if opts.output == "json" {
        return renderer.RenderJSON(map[string]string{
            "status":     "archived",
            "channel_id": channelID,
        })
    }
    
    renderer.Success(fmt.Sprintf("Archived channel: %s", channelID))
    return nil
}

4. Add --no-color Global Flag

  • Add --no-color as persistent flag on root command
  • Pass noColor to Renderer in all commands
  • Respect NO_COLOR environment variable (standard)
  • Update README with flag documentation

Implementation:

func runList(opts *listOptions, c *client.Client) error {
    noColor := opts.noColor
    if os.Getenv("NO_COLOR") != "" {
        noColor = true
    }
    
    renderer := view.NewRenderer(view.Format(opts.output), noColor)
    // ...
}

Acceptance Criteria

  • All commands support --output table|json|plain
  • All commands produce valid JSON with -o json
  • Plain format works with grep/cut/awk: slack-cli channels list -o plain | cut -f1
  • JSON format works with jq: slack-cli channels list -o json | jq '.[].name'
  • --no-color disables all ANSI codes
  • NO_COLOR env var is respected
  • Old --json flag removed (breaking change documented)

Dependencies

  • Blocked By: Phase 3 (restructuring)
  • Blocks: Phase 5 (documentation needs to reflect new flags)

Files to Create

  • internal/view/view.go
  • internal/view/view_test.go

Files to Modify

  • internal/cmd/root/root.go (new flags)
  • All command files (use Renderer)

Testing

Unit Tests:

  • view.ValidateFormat() accepts valid formats, rejects invalid
  • Renderer.RenderTable() produces correct output for each format
  • Renderer.RenderJSON() produces valid JSON
  • noColor disables ANSI codes

Integration Tests:

  • slack-cli channels list -o json | jq . succeeds
  • slack-cli channels list -o plain | cut -f1 shows channel IDs
  • slack-cli channels list -o plain | grep general finds channel

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions