-
Notifications
You must be signed in to change notification settings - Fork 2
Epic: Phase 4 - Output Consistency #4
Copy link
Copy link
Closed
Description
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
--outputflag 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
Formattype (table, json, plain) - Implement
ValidateFormat()function - Implement
Rendererstruct with writer injection - Implement
RenderTable()method with column width calculation - Implement
RenderJSON()method with indentation - Implement
RenderPlain()method (tab-separated for piping) - Implement
Success()andError()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
--jsonglobal flag from root command - Add
--output/-oglobal 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 likesend -
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-coloras persistent flag on root command - Pass noColor to Renderer in all commands
- Respect
NO_COLORenvironment 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-colordisables all ANSI codes -
NO_COLORenv var is respected - Old
--jsonflag 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.gointernal/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 invalidRenderer.RenderTable()produces correct output for each formatRenderer.RenderJSON()produces valid JSON- noColor disables ANSI codes
Integration Tests:
slack-cli channels list -o json | jq .succeedsslack-cli channels list -o plain | cut -f1shows channel IDsslack-cli channels list -o plain | grep generalfinds channel
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels